pear-install 1.0.3 → 1.0.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/README.md CHANGED
@@ -1,14 +1,104 @@
1
- # pear-install
2
-
3
1
  > Install Pear and Pear Applications
4
2
 
5
- # CLI
3
+ ## CLI
4
+
5
+ The published binary. With no `link`, installs the Pear platform. With a `pear://` link, installs that application and/or its binaries.
6
6
 
7
7
  ```sh
8
8
  npx pear-install [link]
9
9
  ```
10
10
 
11
- Install `pear` binary onto system, if a link is supplied, installs any Pear application or binary onto system.
11
+ ### Flags
12
+
13
+ - `--to <dir>` — target directory (overrides platform default)
14
+ - `--only <paths>` — comma-separated filenames to install
15
+ - `--timeout <seconds>` — network timeout (default `30`)
16
+ - `--dht-bootstrap <nodes>` — comma-separated `host:port`
17
+ - `--json` — newline-delimited JSON output (one `{ cmd: 'install', tag, data }` per line)
18
+
19
+ ### Default install destinations
20
+
21
+ - **macOS** — apps to `/Applications`, bins to `/usr/local/bin`
22
+ - **Linux** — apps to `~/Applications`, `~/AppImages`, or `~/.local/bin`; bins to `~/.local/bin`
23
+ - **Windows** — apps and bins installed as MSIX packages
24
+
25
+ ## API
26
+
27
+ ```js
28
+ const Install = require('pear-install')
29
+ ```
30
+
31
+ ### `const install = new Install(opts)`
32
+
33
+ - `link` _(string)_ — `pear://` link
34
+ - `to` _(string, optional)_ — target directory
35
+ - `only` _(string, optional)_ — comma-separated filenames
36
+ - `bootstrap` _(array, optional)_ — `[{ host, port }, ...]` DHT nodes
37
+ - `timeout` _(number, optional)_ — milliseconds, default `30000`
38
+ - `corestore` _(Corestore, optional)_ — inject an existing corestore; not closed by `install.close()`
39
+ - `swarm` _(Hyperswarm, optional)_ — inject an existing swarm; not destroyed by `install.close()`
40
+
41
+ If `corestore` or `swarm` is omitted, `Install` creates and owns its own.
42
+
43
+ ### `await install.ready()`
44
+
45
+ Runs the install. Throws on failure.
46
+
47
+ ### `await install.close()`
48
+
49
+ Releases the drive, the owned swarm and corestore, and removes the temp dir.
50
+
51
+ ### Reusing a corestore and swarm
52
+
53
+ ```js
54
+ const corestore = new Corestore('./store')
55
+ const swarm = new Hyperswarm()
56
+ swarm.on('connection', (c) => corestore.replicate(c))
57
+
58
+ for (const link of links) {
59
+ const install = new Install({ link, corestore, swarm })
60
+ await install.ready()
61
+ await install.close()
62
+ }
63
+
64
+ await swarm.destroy()
65
+ await corestore.close()
66
+ ```
67
+
68
+ ### Events
69
+
70
+ - `installing` → `{ link, host }`
71
+ - `app` → `{ app, name, version, upgrade, key, dest }`
72
+ - `stats` → `{ download, upload, peers }`
73
+ - `final` → `{ success, installed, exists }`
74
+
75
+ ## CMD
76
+
77
+ For embedding in another CLI. Wraps `Install` as a `pear-opstream` of `{ tag, data }` records with stdout formatters.
78
+
79
+ ```js
80
+ const InstallCmd = require('pear-install/cmd')
81
+
82
+ const stream = new InstallCmd({ link, to, only, bootstrap, timeout })
83
+ await InstallCmd.output(json, stream)
84
+ ```
85
+
86
+ ### Tags
87
+
88
+ - `installing` → `{ link, host }`
89
+ - `app` → `{ app, name, version, upgrade, key, dest }`
90
+ - `stats` → `{ download, upload, peers }`
91
+ - `error` → `{ code, message, stack, info, success: false }`
92
+ - `final` → `{ success, installed, exists }`
93
+
94
+ ### `InstallCmd.output(json, stream)`
95
+
96
+ Drains `stream` to STDOUT.
97
+
98
+ - `json` truthy — emits each record as `{ cmd: 'install', tag, data }`, newline-delimited.
99
+ - `json` falsy — formats per-tag; `stats` updates in place via ANSI; static tags stack above.
100
+
101
+ Returns the `final` record.
12
102
 
13
103
  ## License
14
104
 
package/bin.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ const pkg = require('./package.json')
3
+ const { isWindows } = require('which-runtime')
4
+ const { command, arg, bail } = require('paparam')
5
+ const InstallCmd = require('./cmd')
6
+
7
+ const program = command(
8
+ 'install',
9
+ arg('[link]', 'Pear link origin to install from'),
10
+ async (cmd) => {
11
+ const { json, only, to, dhtBootstrap } = cmd.flags
12
+ const timeout = (cmd.flags.timeout || 30) * 1000
13
+ const link = cmd.args.link ?? pkg.pear.platform.key
14
+ const bootstrap = dhtBootstrap
15
+ ? dhtBootstrap.split(',').map((tuple) => {
16
+ const [host, port] = tuple.split(':')
17
+ const int = +port
18
+ if (Number.isInteger(int) === false) throw new Error(`Invalid port: ${port}`)
19
+ return { host, port: int }
20
+ })
21
+ : undefined
22
+ const stream = new InstallCmd({ link, only, to, bootstrap, timeout })
23
+ await InstallCmd.output(json, stream)
24
+ },
25
+ pkg.command,
26
+ bail((info = {}) => {
27
+ process.exitCode = 1
28
+ let message
29
+ if (info.reason === 'UNKNOWN_FLAG') message = 'Unrecognized Flag: --' + info.flag.name
30
+ else if (info.reason === 'UNKNOWN_ARG') {
31
+ message = `Unrecognized Argument at index ${info.arg.index} with value ${info.arg.value}`
32
+ } else message = info.err?.message ?? 'Failed'
33
+ const cross = isWindows ? 'x' : '\x1B[31m✖\x1B[39m'
34
+ console.error(cross, message)
35
+ if (info.reason === 'UNKNOWN_FLAG' || info.reason === 'UNKNOWN_ARG') {
36
+ console.error('\n' + info.command.usage())
37
+ }
38
+ })
39
+ )
40
+
41
+ program.parse(process.argv.slice(2))
package/cmd.js CHANGED
@@ -1,30 +1,75 @@
1
- #!/usr/bin/env node
2
- const pkg = require('./package.json')
1
+ 'use strict'
2
+ const process = require('process')
3
+ const Opstream = require('pear-opstream')
4
+ const byteSize = require('tiny-byte-size')
3
5
  const { isWindows } = require('which-runtime')
4
- const { command, arg, bail } = require('paparam')
5
- const install = require('.')
6
+ const Install = require('.')
6
7
 
7
- const program = command(
8
- 'install',
9
- arg('[link]', 'Pear link origin to install from'),
10
- async (cmd) => {
11
- if (!cmd.args.link) cmd.args.link = pkg.pear.platform.key
12
- await install(cmd)
13
- },
14
- pkg.command,
15
- bail((info = {}) => {
16
- process.exitCode = 1
17
- let message
18
- if (info.reason === 'UNKNOWN_FLAG') message = 'Unrecognized Flag: --' + info.flag.name
19
- else if (info.reason === 'UNKNOWN_ARG') {
20
- message = `Unrecognized Argument at index ${info.arg.index} with value ${info.arg.value}`
21
- } else message = info.err?.message ?? 'Failed'
22
- const cross = isWindows ? 'x' : '\x1B[31m✖\x1B[39m'
23
- console.error(cross, message)
24
- if (info.reason === 'UNKNOWN_FLAG' || info.reason === 'UNKNOWN_ARG') {
25
- console.error('\n' + info.command.usage())
8
+ const down = isWindows ? '↓' : '⬇'
9
+
10
+ class InstallCmd extends Opstream {
11
+ static outputs = {
12
+ installing: ({ link }) => `Installing... ${link}`,
13
+ app: ({ app, version, upgrade, dest, key }) =>
14
+ `App: ${app}\nVersion: ${version}\nLink: ${upgrade}\nPathname: ${key}\nTarget: ${dest}`,
15
+ stats({ download, peers }) {
16
+ const dl =
17
+ download.bytes + download.speed === 0
18
+ ? ''
19
+ : ` [ ${down} ${byteSize(download.bytes)} - ${byteSize(download.speed)}/s ] `
20
+ return `[ Peers: ${peers} ]${dl}`
21
+ },
22
+ error: ({ message }) => message,
23
+ final({ success, message }) {
24
+ if (success) return 'Installed'
25
+ return message ?? 'Failed'
26
+ }
27
+ }
28
+ static async output(json, stream) {
29
+ let status = false
30
+ for await (const { tag, data } of stream) {
31
+ if (json) {
32
+ process.stdout.write(JSON.stringify({ cmd: 'install', tag, data }) + '\n')
33
+ continue
34
+ }
35
+ if (!this.outputs[tag]) continue
36
+ const line = this.outputs[tag](data)
37
+ const clear = status ? '\r\x1B[2K' : ''
38
+ if (tag === 'stats') {
39
+ process.stdout.write('\r\x1B[2K' + line)
40
+ status = true
41
+ continue
42
+ }
43
+ process.stdout.write(clear + line + '\n')
44
+ status = false
45
+ if (tag === 'final') return data
46
+ }
47
+ }
48
+
49
+ constructor(params) {
50
+ super((...args) => this.#op(...args), params)
51
+ }
52
+
53
+ async #op(opts) {
54
+ const install = new Install(opts)
55
+ install.on('installing', (data) => {
56
+ this.push({ tag: 'installing', data })
57
+ })
58
+ install.on('app', (data) => {
59
+ this.push({ tag: 'app', data })
60
+ })
61
+ install.on('stats', (data) => {
62
+ this.push({ tag: 'stats', data })
63
+ })
64
+ install.on('final', (data) => {
65
+ this.final = data
66
+ })
67
+ try {
68
+ await install.ready()
69
+ } finally {
70
+ await install.close()
26
71
  }
27
- })
28
- )
72
+ }
73
+ }
29
74
 
30
- program.parse(process.argv.slice(2))
75
+ module.exports = InstallCmd
package/index.js CHANGED
@@ -11,9 +11,8 @@ const Corestore = require('corestore')
11
11
  const Hyperdrive = require('hyperdrive')
12
12
  const Hyperswarm = require('hyperswarm')
13
13
  const plink = require('pear-link')
14
- const Opstream = require('pear-opstream')
15
14
  const PearError = require('pear-errors')
16
- const byteSize = require('tiny-byte-size')
15
+ const ReadyResource = require('ready-resource')
17
16
  const { ERR_INVALID_MANIFEST, ERR_NOT_FOUND, ERR_PERMISSION_REQUIRED, ERR_UNKNOWN } = PearError
18
17
 
19
18
  function ERR_NETWORK_TIMEOUT(msg, info = null) {
@@ -30,245 +29,220 @@ const PEAR_DIR = isMac
30
29
  ? path.join(os.homedir(), '.config', 'pear')
31
30
  : path.join(os.homedir(), 'AppData', 'Roaming', 'pear')
32
31
 
33
- class Install extends Opstream {
34
- static outputs = {
35
- installing: ({ link }) => `Installing... ${link}`,
36
- app: ({ app, version, upgrade, dest, key }) =>
37
- `App: ${app}\nVersion: ${version}\nLink: ${upgrade}\nPathname: ${key}\nTarget: ${dest}`,
38
- stats({ upload, download, peers }) {
39
- const dl =
40
- download.bytes + download.speed === 0
41
- ? ''
42
- : `[ down ${byteSize(download.bytes)} - ${byteSize(download.speed)}/s ] `
43
- const ul =
44
- upload.bytes + upload.speed === 0
45
- ? ''
46
- : `[ up ${byteSize(upload.bytes)} - ${byteSize(upload.speed)}/s ] `
47
- return `[ Peers: ${peers} ] ${dl}${ul}`
48
- },
49
- error: ({ message }) => message,
50
- final({ success, message }) {
51
- if (success) return 'Installed'
52
- return message ?? 'Failed'
53
- }
54
- }
55
-
56
- constructor(params) {
57
- super((...args) => this.#op(...args), params)
32
+ class Install extends ReadyResource {
33
+ constructor({ link, only, to, bootstrap, timeout = 30_000, corestore = null, swarm = null }) {
34
+ super()
35
+ this.doneFinding = null
36
+ this.link = link
37
+ this.only = only
38
+ this.to = to
39
+ this.bootstrap = bootstrap
40
+ this.timeout = timeout
58
41
  this.targets = []
42
+ this.base = null
43
+ this.drive = null
44
+ this.corestore = corestore
45
+ this.swarm = swarm
46
+ this._ownCorestore = corestore === null
47
+ this._ownSwarm = swarm === null
59
48
  }
60
-
61
- async #op({ link, only, to, bootstrap, timeout = 30_000 }) {
49
+ async _open() {
50
+ try {
51
+ await this._install()
52
+ } catch (err) {
53
+ await this._teardown()
54
+ throw err
55
+ }
56
+ }
57
+ async _install() {
58
+ const { link, only, to, bootstrap, timeout = 30_000 } = this
62
59
  const parsed = plink.parse(link)
63
60
  if (parsed.pathname) throw new Error('Link must not have pathname')
64
61
  const host = process.platform + '-' + process.arch
65
- this.push({ tag: 'installing', data: { link, host } })
62
+ this.emit('installing', { link, host })
66
63
 
67
64
  const rand = crypto.randomBytes(16).toString('hex')
68
- const base = path.join(PEAR_DIR, 'gc', rand)
69
- fs.mkdirSync(base, { recursive: true })
70
-
71
- const corestore = new Corestore(base)
72
- const drive = new Hyperdrive(corestore, parsed.drive.key)
73
- const swarm = new Hyperswarm({ bootstrap })
74
-
75
- let findingDone = null
76
- try {
77
- await drive.ready()
78
- findingDone = drive.findingPeers()
79
- const topic = swarm.join(drive.discoveryKey, { server: false, client: true })
80
- swarm.on('connection', (c) => corestore.replicate(c))
81
-
82
- let serving = false
83
- swarm.dht.on('nat-update', () => {
84
- if (!swarm.dht.randomized && !serving) {
85
- serving = true
86
- swarm
87
- .join(drive.discoveryKey, { server: true, client: false })
88
- .flushed()
89
- .then(() => topic.destroy())
90
- }
91
- })
92
- const deferred = Promise.withResolvers()
93
- const countdown = setTimeout(() => {
94
- deferred.reject(ERR_NETWORK_TIMEOUT('Network Timeout ' + timeout / 1000 + 's'))
95
- }, timeout)
96
- await Promise.race([drive.core.update({ wait: true }), deferred.promise])
97
- clearTimeout(countdown)
98
- const pkg = await drive.get('/package.json')
99
- if (pkg === null) throw ERR_INVALID_MANIFEST('Unable to read application package.json')
100
- const manifest = JSON.parse(pkg.toString())
101
-
102
- const { name, productName, version, upgrade, bin } = manifest
103
- const appName = productName ?? name
104
- const home = os.homedir()
105
-
106
- if (bin) {
107
- const bins = typeof bin === 'string' ? { [name]: bin } : bin
108
- for (const binName of Object.keys(bins)) {
109
- const ext = isWindows ? '.msix' : ''
110
- const dest = isWindows
111
- ? null
112
- : to
113
- ? path.join(to, binName + ext)
114
- : isMac
115
- ? path.join('/', 'usr', 'local', 'bin', binName)
116
- : path.join(home, '.local', 'bin', binName)
117
- this.targets.push({ filename: binName, ext, dest, isBin: true })
118
- }
65
+ this.base = path.join(PEAR_DIR, 'gc', rand)
66
+ fs.mkdirSync(this.base, { recursive: true })
67
+
68
+ if (this._ownCorestore) this.corestore = new Corestore(this.base)
69
+ if (this._ownSwarm) this.swarm = new Hyperswarm({ bootstrap })
70
+
71
+ this.drive = new Hyperdrive(this.corestore.session(), parsed.drive.key)
72
+
73
+ await this.drive.ready()
74
+ this.doneFinding = this.drive.findingPeers()
75
+ const topic = this.swarm.join(this.drive.discoveryKey, { server: false, client: true })
76
+ this.swarm.on('connection', (c) => this.corestore.replicate(c))
77
+
78
+ let serving = false
79
+ this.swarm.dht.on('nat-update', () => {
80
+ if (!this.swarm.dht.randomized && !serving) {
81
+ serving = true
82
+ this.swarm
83
+ .join(this.drive.discoveryKey, { server: true, client: false })
84
+ .flushed()
85
+ .then(() => topic.destroy())
119
86
  }
120
-
121
- const ext = isMac ? '.app' : isWindows ? '.msix' : '.AppImage'
122
- const dest = isWindows
123
- ? null
124
- : to
125
- ? path.join(to, appName + ext)
126
- : isMac
127
- ? path.join('/', 'Applications', appName + ext)
128
- : fs.existsSync(path.join(home, 'Applications'))
129
- ? path.join(home, 'Applications', appName + ext)
130
- : fs.existsSync(path.join(home, 'AppImages'))
131
- ? path.join(home, 'AppImages', appName + ext)
132
- : path.join(home, '.local', 'bin', appName + ext)
133
-
134
- this.targets.push({ filename: appName, ext, dest, isBin: false })
135
-
136
- const present = new Set()
137
- const appPath = '/by-arch/' + host + '/app/'
138
- for await (const name of drive.readdir(appPath)) present.add(name)
139
-
140
- const required = only
141
- ? only
142
- .split(',')
143
- .map((s) => s.trim())
144
- .filter(Boolean)
145
- : this.targets.filter((t) => t.isBin || !bin).map((t) => t.filename + t.ext)
146
- const missing = required
147
- .filter((r) => !present.has(r))
148
- .map((name) => plink.serialize({ ...parsed, pathname: appPath + name }))
149
- if (missing.length) {
150
- throw ERR_NOT_FOUND('Not found: ' + missing.join(', '))
87
+ })
88
+ const deferred = Promise.withResolvers()
89
+ const countdown = setTimeout(() => {
90
+ deferred.reject(ERR_NETWORK_TIMEOUT('Network Timeout ' + timeout / 1000 + 's'))
91
+ }, timeout)
92
+ await Promise.race([this.drive.core.update({ wait: true }), deferred.promise])
93
+ clearTimeout(countdown)
94
+ const pkg = await this.drive.get('/package.json')
95
+ if (pkg === null) throw ERR_INVALID_MANIFEST('Unable to read application package.json')
96
+ const manifest = JSON.parse(pkg.toString())
97
+
98
+ const { name, productName, version, upgrade, bin } = manifest
99
+ const appName = productName ?? name
100
+ const home = os.homedir()
101
+
102
+ if (bin) {
103
+ const bins = typeof bin === 'string' ? { [name]: bin } : bin
104
+ for (const binName of Object.keys(bins)) {
105
+ const ext = isWindows ? '.msix' : ''
106
+ const dest = isWindows
107
+ ? null
108
+ : to
109
+ ? path.join(to, binName + ext)
110
+ : isMac
111
+ ? path.join('/', 'usr', 'local', 'bin', binName)
112
+ : path.join(home, '.local', 'bin', binName)
113
+ this.targets.push({ filename: binName, ext, dest, isBin: true })
151
114
  }
115
+ }
152
116
 
153
- this.targets = this.targets.filter(({ filename, ext }) => present.has(filename + ext))
154
-
155
- const exists = []
156
- const installs = []
157
- for (const target of this.targets) {
158
- if (isWindows) {
159
- const ps = spawnSync('powershell', [
160
- '-NoProfile',
161
- '-Command',
162
- `(Get-AppxPackage '${target.filename}') -ne $null`
163
- ])
164
- if (ps.stdout.toString().trim() === 'True') {
165
- exists.push({ filename: target.filename, dest: target.dest })
166
- continue
167
- }
168
- } else if (fs.existsSync(target.dest)) {
117
+ const ext = isMac ? '.app' : isWindows ? '.msix' : '.AppImage'
118
+ const dest = isWindows
119
+ ? null
120
+ : to
121
+ ? path.join(to, appName + ext)
122
+ : isMac
123
+ ? path.join('/', 'Applications', appName + ext)
124
+ : fs.existsSync(path.join(home, 'Applications'))
125
+ ? path.join(home, 'Applications', appName + ext)
126
+ : fs.existsSync(path.join(home, 'AppImages'))
127
+ ? path.join(home, 'AppImages', appName + ext)
128
+ : path.join(home, '.local', 'bin', appName + ext)
129
+
130
+ this.targets.push({ filename: appName, ext, dest, isBin: false })
131
+
132
+ const present = new Set()
133
+ const appPath = '/by-arch/' + host + '/app/'
134
+ for await (const name of this.drive.readdir(appPath)) present.add(name)
135
+
136
+ const required = only
137
+ ? only
138
+ .split(',')
139
+ .map((s) => s.trim())
140
+ .filter(Boolean)
141
+ : this.targets.filter((t) => t.isBin || !bin).map((t) => t.filename + t.ext)
142
+ const missing = required
143
+ .filter((r) => !present.has(r))
144
+ .map((name) => plink.serialize({ ...parsed, pathname: appPath + name }))
145
+ if (missing.length) {
146
+ throw ERR_NOT_FOUND('Not found: ' + missing.join(', '))
147
+ }
148
+
149
+ this.targets = this.targets.filter(({ filename, ext }) => present.has(filename + ext))
150
+
151
+ const exists = []
152
+ const installs = []
153
+ for (const target of this.targets) {
154
+ if (isWindows) {
155
+ const ps = spawnSync('powershell', [
156
+ '-NoProfile',
157
+ '-Command',
158
+ `(Get-AppxPackage '${target.filename}') -ne $null`
159
+ ])
160
+ if (ps.stdout.toString().trim() === 'True') {
169
161
  exists.push({ filename: target.filename, dest: target.dest })
170
162
  continue
171
163
  }
172
- installs.push(target)
164
+ } else if (fs.existsSync(target.dest)) {
165
+ exists.push({ filename: target.filename, dest: target.dest })
166
+ continue
173
167
  }
168
+ installs.push(target)
169
+ }
174
170
 
175
- if (installs.length === 0) {
176
- const message = isWindows
177
- ? `Already installed:\n${exists.map(({ filename }) => ' ' + filename).join('\n')}\n Manually uninstall first to reinstall`
178
- : `Refusing to overwrite existing:\n${exists.map(({ dest }) => ' ' + dest).join('\n')}\n Manually remove first to reinstall`
179
- throw ERR_EXISTS(message)
180
- }
171
+ if (installs.length === 0) {
172
+ const message = isWindows
173
+ ? `Already installed:\n${exists.map(({ filename }) => ' ' + filename).join('\n')}\n Manually uninstall first to reinstall`
174
+ : `Refusing to overwrite existing:\n${exists.map(({ dest }) => ' ' + dest).join('\n')}\n Manually remove first to reinstall`
175
+ throw ERR_EXISTS(message)
176
+ }
181
177
 
182
- const tmp = path.join(base, 'targets')
183
- fs.mkdirSync(tmp, { recursive: true })
184
- const prefixes = installs.map(({ filename, ext }) => appPath + filename + ext)
185
- const mirror = drive.mirror(new LocalDrive(tmp), {
186
- prefix: prefixes,
187
- prune: false,
188
- progress: true,
189
- dedup: true
190
- })
191
- const monitor = mirror.monitor()
192
- monitor.on('update', (stats) => this.push({ tag: 'stats', data: stats }))
193
- await mirror.done()
194
- monitor.destroy()
195
-
196
- let installed = 0
197
- for (const { filename, ext, dest, isBin } of installs) {
198
- const key = appPath + filename + ext
199
- this.push({ tag: 'app', data: { app: filename, name, version, upgrade, key, tmp, dest } })
200
-
201
- const from = path.join(tmp, 'by-arch', host, 'app', filename + ext)
202
-
203
- if (fs.existsSync(from) === false) {
204
- throw ERR_NOT_FOUND(plink.serialize({ ...parsed, pathname: key }))
205
- }
178
+ const tmp = path.join(this.base, 'targets')
179
+ fs.mkdirSync(tmp, { recursive: true })
180
+ const prefixes = installs.map(({ filename, ext }) => appPath + filename + ext)
181
+ const mirror = this.drive.mirror(new LocalDrive(tmp), {
182
+ prefix: prefixes,
183
+ prune: false,
184
+ progress: true,
185
+ dedup: true
186
+ })
187
+ const monitor = mirror.monitor()
188
+ monitor.on('update', (stats) => this.emit('stats', stats))
189
+ await mirror.done()
190
+ monitor.destroy()
206
191
 
207
- if (isWindows) {
208
- const MSIXManager = require('msix-manager')
209
- await new MSIXManager().addPackage(from)
210
- installed++
211
- continue
212
- }
192
+ let installed = 0
193
+ for (const { filename, ext, dest, isBin } of installs) {
194
+ const key = appPath + filename + ext
195
+ this.emit('app', { app: filename, name, version, upgrade, key, tmp, dest })
213
196
 
214
- if (isBin) {
215
- try {
216
- if (!to) fs.mkdirSync(path.dirname(dest), { recursive: true })
217
- this._move(from, dest)
218
- } catch (err) {
219
- if (err.code === 'EACCES' || err.code === 'EPERM') {
220
- const dir = path.dirname(dest)
221
- const fix = isMac
222
- ? `sudo chgrp admin ${dir} && sudo chmod g+w ${dir}`
223
- : `sudo chown -R "$(id -un):$(id -gn)" ${dir}`
224
- throw ERR_PERMISSION_REQUIRED(`Permission denied: ${dest}\n Fix: ${fix}`)
225
- }
226
- throw err
227
- }
228
- fs.chmodSync(dest, 0o755)
229
- } else {
230
- try {
231
- await fs.promises.rename(from, dest)
232
- } catch (err) {
233
- if (err.code === 'EACCES' || err.code === 'EPERM') {
234
- const dir = path.dirname(dest)
235
- const fix = isMac
236
- ? `sudo chgrp admin ${dir} && sudo chmod g+w ${dir}`
237
- : `sudo chown -R "$(id -un):$(id -gn)" ${dir}`
238
- throw ERR_PERMISSION_REQUIRED(`Permission denied: ${dest}\n Fix: ${fix}`)
239
- }
240
- throw err
241
- }
242
- if (isLinux) await this._linux(dest, filename, tmp, home)
243
- }
244
- installed++
245
- }
246
- if (installed === 0) {
247
- throw ERR_UNKNOWN('Failed to install')
197
+ const from = path.join(tmp, 'by-arch', host, 'app', filename + ext)
198
+
199
+ if (fs.existsSync(from) === false) {
200
+ throw ERR_NOT_FOUND(plink.serialize({ ...parsed, pathname: key }))
248
201
  }
249
- this.final = { success: true, installed, exists }
250
- } finally {
251
- if (findingDone) findingDone()
252
- await drive.close()
253
- await swarm.destroy()
254
- await corestore.close()
255
- fs.rmSync(base, { recursive: true, force: true })
256
- }
257
- }
258
202
 
259
- static async output(json, stream) {
260
- for await (const { tag, data } of stream) {
261
- if (json) {
262
- process.stdout.write(JSON.stringify({ cmd: 'install', tag, data }) + '\n')
203
+ if (isWindows) {
204
+ const MSIXManager = require('msix-manager')
205
+ await new MSIXManager().addPackage(from)
206
+ installed++
263
207
  continue
264
208
  }
265
- if (tag === 'final') {
266
- process.stdout.write('\r\x1B[2K' + this.outputs.final(data) + '\n')
267
- return data
268
- } else if (this.outputs[tag]) {
269
- process.stdout.write('\r\x1B[2K' + this.outputs[tag](data) + '\n')
209
+
210
+ if (isBin) {
211
+ try {
212
+ if (!to) fs.mkdirSync(path.dirname(dest), { recursive: true })
213
+ this._move(from, dest)
214
+ } catch (err) {
215
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
216
+ const dir = path.dirname(dest)
217
+ const fix = isMac
218
+ ? `sudo chgrp admin ${dir} && sudo chmod g+w ${dir}`
219
+ : `sudo chown -R "$(id -un):$(id -gn)" ${dir}`
220
+ throw ERR_PERMISSION_REQUIRED(`Permission denied: ${dest}\n Fix: ${fix}`)
221
+ }
222
+ throw err
223
+ }
224
+ fs.chmodSync(dest, 0o755)
225
+ } else {
226
+ try {
227
+ await fs.promises.rename(from, dest)
228
+ } catch (err) {
229
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
230
+ const dir = path.dirname(dest)
231
+ const fix = isMac
232
+ ? `sudo chgrp admin ${dir} && sudo chmod g+w ${dir}`
233
+ : `sudo chown -R "$(id -un):$(id -gn)" ${dir}`
234
+ throw ERR_PERMISSION_REQUIRED(`Permission denied: ${dest}\n Fix: ${fix}`)
235
+ }
236
+ throw err
237
+ }
238
+ if (isLinux) await this._linux(dest, filename, tmp, home)
270
239
  }
240
+ installed++
271
241
  }
242
+ if (installed === 0) {
243
+ throw ERR_UNKNOWN('Failed to install')
244
+ }
245
+ this.emit('final', { success: true, installed, exists })
272
246
  }
273
247
 
274
248
  async _linux(dest, appName, tmp, home) {
@@ -324,20 +298,35 @@ class Install extends Opstream {
324
298
  fs.rmSync(src)
325
299
  }
326
300
  }
327
- }
328
301
 
329
- module.exports = async function (cmd) {
330
- const { json, only, to, dhtBootstrap } = cmd.flags
331
- const timeout = (cmd.flags.timeout || 30) * 1000
332
- const link = cmd.args.link
333
- const bootstrap = dhtBootstrap
334
- ? dhtBootstrap.split(',').map((tuple) => {
335
- const [host, port] = tuple.split(':')
336
- const int = +port
337
- if (Number.isInteger(int) === false) throw new Error(`Invalid port: ${port}`)
338
- return { host, port: int }
339
- })
340
- : undefined
341
- const stream = new Install({ link, only, to, bootstrap, timeout })
342
- await Install.output(json, stream)
302
+ async _close() {
303
+ await this._teardown()
304
+ }
305
+
306
+ async _teardown() {
307
+ if (this.doneFinding) {
308
+ this.doneFinding()
309
+ this.doneFinding = null
310
+ }
311
+ if (this.drive) {
312
+ await this.drive.close()
313
+ this.drive = null
314
+ }
315
+ if (this.swarm && this._ownSwarm) {
316
+ await this.swarm.destroy()
317
+ this.swarm = null
318
+ }
319
+ if (this.corestore && this._ownCorestore) {
320
+ await this.corestore.close()
321
+ this.corestore = null
322
+ }
323
+ if (this.base) {
324
+ try {
325
+ fs.rmSync(this.base, { recursive: true, force: true })
326
+ } catch {}
327
+ this.base = null
328
+ }
329
+ }
343
330
  }
331
+
332
+ module.exports = Install
package/package.json CHANGED
@@ -1,13 +1,18 @@
1
1
  {
2
2
  "name": "pear-install",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "main": "index.js",
5
5
  "type": "commonjs",
6
6
  "description": "Install Pear and Pear Applications",
7
7
  "author": "Holepunch Inc",
8
8
  "license": "Apache-2.0",
9
- "bin": {
10
- "pear-install": "cmd.js"
9
+ "files": [
10
+ "cmd.js"
11
+ ],
12
+ "bin": "bin.js",
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./cmd": "./cmd.js"
11
16
  },
12
17
  "imports": {
13
18
  "process": {
package/.gitattributes DELETED
@@ -1 +0,0 @@
1
- * text=auto eol=lf
@@ -1,17 +0,0 @@
1
- name: Publish
2
- on:
3
- push:
4
- tags:
5
- - v*
6
- permissions:
7
- id-token: write
8
- contents: write
9
- jobs:
10
- publish:
11
- runs-on: ubuntu-latest
12
- environment:
13
- name: npm
14
- name: Publish
15
- steps:
16
- - uses: holepunchto/actions/node-base@v1
17
- - uses: holepunchto/actions/publish@v1
@@ -1,32 +0,0 @@
1
- name: Test
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- lint:
11
- runs-on: ubuntu-latest
12
- name: Lint
13
- steps:
14
- - uses: holepunchto/actions/node-base@v1
15
- - run: npm run lint
16
-
17
- test:
18
- strategy:
19
- matrix:
20
- include:
21
- - os: ubuntu-latest
22
- platform: linux
23
- - os: macos-latest
24
- platform: darwin
25
- - os: windows-latest
26
- platform: win32
27
- runs-on: ${{ matrix.os }}
28
- name: Test / ${{ matrix.platform }}
29
- timeout-minutes: 2
30
- steps:
31
- - uses: holepunchto/actions/bare-base@v1
32
- - run: npm test
package/.prettierignore DELETED
@@ -1 +0,0 @@
1
- template/appling/package.json
package/.prettierrc DELETED
@@ -1 +0,0 @@
1
- "prettier-config-holepunch"
package/NOTICE DELETED
@@ -1,13 +0,0 @@
1
- Copyright 2026 Holepunch Inc
2
-
3
- Licensed under the Apache License, Version 2.0 (the "License");
4
- you may not use this file except in compliance with the License.
5
- You may obtain a copy of the License at
6
-
7
- http://www.apache.org/licenses/LICENSE-2.0
8
-
9
- Unless required by applicable law or agreed to in writing, software
10
- distributed under the License is distributed on an "AS IS" BASIS,
11
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- See the License for the specific language governing permissions and
13
- limitations under the License.
package/test/all.js DELETED
@@ -1,13 +0,0 @@
1
- // This runner is auto-generated by Brittle
2
-
3
- runTests()
4
-
5
- async function runTests() {
6
- const test = (await import('brittle')).default
7
-
8
- test.pause()
9
-
10
- await import('./index.test.js')
11
-
12
- test.resume()
13
- }
package/test/helper.js DELETED
@@ -1,80 +0,0 @@
1
- 'use strict'
2
- const Corestore = require('corestore')
3
- const Hyperdrive = require('hyperdrive')
4
- const Hyperswarm = require('hyperswarm')
5
- const tmp = require('test-tmp')
6
- const { command, arg, bail } = require('paparam')
7
- const pkg = require('../package.json')
8
- const install = require('..')
9
- const process = require('process')
10
- const arch = `${process.platform}-${process.arch}`
11
-
12
- async function seed(t, { bootstrap, manifest, files }) {
13
- const storage = await tmp(t)
14
- const corestore = new Corestore(storage)
15
- const drive = new Hyperdrive(corestore)
16
- await drive.ready()
17
- await drive.put('/package.json', Buffer.from(JSON.stringify(manifest)))
18
- for (const [p, content] of Object.entries(files)) {
19
- await drive.put(p, Buffer.from(content))
20
- }
21
- const swarm = new Hyperswarm({ bootstrap })
22
- swarm.on('connection', (c) => corestore.replicate(c))
23
- swarm.join(drive.discoveryKey, { server: true, client: false })
24
- await swarm.flush()
25
- t.teardown(async () => {
26
- await swarm.destroy()
27
- await drive.close()
28
- await corestore.close()
29
- })
30
- return drive.key
31
- }
32
-
33
- async function run(args) {
34
- const lines = []
35
- let buf = ''
36
- const origWrite = process.stdout.write
37
- const origExitCode = process.exitCode
38
- process.stdout.write = function (chunk) {
39
- buf += chunk.toString()
40
- let i
41
- while ((i = buf.indexOf('\n')) !== -1) {
42
- lines.push(buf.slice(0, i))
43
- buf = buf.slice(i + 1)
44
- }
45
- return true
46
- }
47
- let bailInfo = null
48
- let exitCode
49
- try {
50
- const program = command(
51
- 'install',
52
- arg('[link]', 'Pear link'),
53
- pkg.command,
54
- install,
55
- bail((info) => {
56
- bailInfo = info
57
- })
58
- )
59
- const c = program.parse(args, { run: true })
60
- if (c?.running) await c.running.catch(() => {})
61
- } finally {
62
- process.stdout.write = origWrite
63
- exitCode = process.exitCode
64
- process.exitCode = origExitCode
65
- }
66
- const events = lines.flatMap((l) => {
67
- try {
68
- return [JSON.parse(l)]
69
- } catch {
70
- return []
71
- }
72
- })
73
- return { events, bail: bailInfo, stdout: lines.join('\n') + buf, exitCode }
74
- }
75
-
76
- function bootstrapArg(testnet) {
77
- return testnet.bootstrap.map((b) => `${b.host}:${b.port}`).join(',')
78
- }
79
-
80
- module.exports = { seed, run, arch, bootstrapArg }
@@ -1,238 +0,0 @@
1
- 'use strict'
2
- const test = require('brittle')
3
- const path = require('path')
4
- const fs = require('fs')
5
- const createTestnet = require('hyperdht/testnet')
6
- const hypercoreCrypto = require('hypercore-crypto')
7
- const hid = require('hypercore-id-encoding')
8
- const plink = require('pear-link')
9
- const { isWindows } = require('which-runtime')
10
- const tmp = require('test-tmp')
11
- const { seed, run, arch, bootstrapArg } = require('./helper')
12
-
13
- test('successful bin install via testnet', { skip: isWindows }, async function (t) {
14
- t.timeout(60000)
15
- const testnet = await createTestnet(3, t.teardown)
16
- const key = await seed(t, {
17
- bootstrap: testnet.bootstrap,
18
- manifest: { name: 'tbin', version: '1.0.0', upgrade: 'pear://x', bin: 'cli.js' },
19
- files: { ['/by-arch/' + arch + '/app/tbin']: 'BIN' }
20
- })
21
- const link = plink.serialize({ drive: { key } })
22
- const target = await tmp(t)
23
- const { events } = await run([
24
- '--json',
25
- '--to',
26
- target,
27
- '--dht-bootstrap',
28
- bootstrapArg(testnet),
29
- link
30
- ])
31
- const final = events.find((e) => e.tag === 'final')
32
- t.ok(final, 'final event emitted')
33
- t.is(final.data.success, true, 'success')
34
- t.is(fs.readFileSync(path.join(target, 'tbin'), 'utf8'), 'BIN', 'bin written to target')
35
- })
36
-
37
- test('notFound emits full pear:// link when platform binary missing', async function (t) {
38
- t.timeout(60000)
39
- const testnet = await createTestnet(3, t.teardown)
40
- const key = await seed(t, {
41
- bootstrap: testnet.bootstrap,
42
- manifest: { name: 'tbin', version: '1.0.0', upgrade: 'pear://x', bin: 'cli.js' },
43
- files: {}
44
- })
45
- const link = plink.serialize({ drive: { key } })
46
- const target = await tmp(t)
47
- const { events } = await run([
48
- '--json',
49
- '--to',
50
- target,
51
- '--dht-bootstrap',
52
- bootstrapArg(testnet),
53
- link
54
- ])
55
- const final = events.find((e) => e.tag === 'final')
56
- const error = events.find((e) => e.tag === 'error')
57
- t.ok(final, 'final event emitted')
58
- t.is(final.data.success, false, 'failure')
59
- t.is(error?.data.code, 'ERR_NOT_FOUND', 'ERR_NOT_FOUND emitted')
60
- t.ok(error?.data.message.includes('pear://'), 'error message includes pear link')
61
- t.ok(
62
- error?.data.message.includes('/by-arch/' + arch + '/app/tbin'),
63
- 'error message includes platform path'
64
- )
65
- })
66
-
67
- test('refuses to overwrite an existing install', { skip: isWindows }, async function (t) {
68
- t.timeout(60000)
69
- const testnet = await createTestnet(3, t.teardown)
70
- const key = await seed(t, {
71
- bootstrap: testnet.bootstrap,
72
- manifest: { name: 'tbin', version: '1.0.0', upgrade: 'pear://x', bin: 'cli.js' },
73
- files: { ['/by-arch/' + arch + '/app/tbin']: 'NEW' }
74
- })
75
- const link = plink.serialize({ drive: { key } })
76
- const target = await tmp(t)
77
- fs.writeFileSync(path.join(target, 'tbin'), 'EXISTING')
78
- const { events } = await run([
79
- '--json',
80
- '--to',
81
- target,
82
- '--dht-bootstrap',
83
- bootstrapArg(testnet),
84
- link
85
- ])
86
- const final = events.find((e) => e.tag === 'final')
87
- const error = events.find((e) => e.tag === 'error')
88
- t.ok(final, 'final event emitted')
89
- t.is(final.data.success, false, 'failure')
90
- t.is(error?.data.code, 'ERR_EXISTS', 'ERR_EXISTS emitted')
91
- t.ok(
92
- error?.data.message.includes('Refusing to overwrite existing'),
93
- 'error message reports refusal'
94
- )
95
- t.ok(
96
- error?.data.message.includes(path.join(target, 'tbin')),
97
- 'error message lists existing target path'
98
- )
99
- t.is(fs.readFileSync(path.join(target, 'tbin'), 'utf8'), 'EXISTING', 'existing file untouched')
100
- })
101
-
102
- test('invalid port in --dht-bootstrap reaches bail handler', async function (t) {
103
- const { bail: info } = await run([
104
- '--dht-bootstrap',
105
- '127.0.0.1:nope',
106
- 'pear://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
107
- ])
108
- t.ok(info, 'bail handler invoked')
109
- t.is(info.err?.message, 'Invalid port: nope')
110
- })
111
-
112
- test('link with pathname emits error event', async function (t) {
113
- const key = hid.encode(hypercoreCrypto.keyPair().publicKey)
114
- const link = `pear://${key}/some/path`
115
- const { events } = await run(['--json', link])
116
- const error = events.find((e) => e.tag === 'error')
117
- t.ok(error, 'error event emitted')
118
- t.is(error.data.message, 'Link must not have pathname')
119
- })
120
-
121
- test(
122
- 'non-json output prints installing, app and Installed',
123
- { skip: isWindows },
124
- async function (t) {
125
- t.timeout(60000)
126
- const testnet = await createTestnet(3, t.teardown)
127
- const key = await seed(t, {
128
- bootstrap: testnet.bootstrap,
129
- manifest: { name: 'tbin', version: '1.0.0', upgrade: 'pear://x', bin: 'cli.js' },
130
- files: { ['/by-arch/' + arch + '/app/tbin']: 'BIN' }
131
- })
132
- const link = plink.serialize({ drive: { key } })
133
- const target = await tmp(t)
134
- const { stdout } = await run(['--to', target, '--dht-bootstrap', bootstrapArg(testnet), link])
135
- t.ok(stdout.includes('Installing...'), 'installing message printed')
136
- t.ok(stdout.includes('App: tbin'), 'app message printed')
137
- t.ok(stdout.includes('Installed'), 'final installed message printed')
138
- }
139
- )
140
-
141
- test(
142
- 'non-json output prints Not found for missing platform binary',
143
- { skip: isWindows },
144
- async function (t) {
145
- t.timeout(60000)
146
- const testnet = await createTestnet(3, t.teardown)
147
- const key = await seed(t, {
148
- bootstrap: testnet.bootstrap,
149
- manifest: { name: 'tbin', version: '1.0.0', upgrade: 'pear://x', bin: 'cli.js' },
150
- files: {}
151
- })
152
- const link = plink.serialize({ drive: { key } })
153
- const target = await tmp(t)
154
- const { stdout } = await run(['--to', target, '--dht-bootstrap', bootstrapArg(testnet), link])
155
- t.ok(stdout.includes('Not found: pear://'), 'not found message printed')
156
- }
157
- )
158
-
159
- test(
160
- 'non-json output prints Refusing to overwrite for existing target',
161
- { skip: isWindows },
162
- async function (t) {
163
- t.timeout(60000)
164
- const testnet = await createTestnet(3, t.teardown)
165
- const key = await seed(t, {
166
- bootstrap: testnet.bootstrap,
167
- manifest: { name: 'tbin', version: '1.0.0', upgrade: 'pear://x', bin: 'cli.js' },
168
- files: { ['/by-arch/' + arch + '/app/tbin']: 'BIN' }
169
- })
170
- const link = plink.serialize({ drive: { key } })
171
- const target = await tmp(t)
172
- fs.writeFileSync(path.join(target, 'tbin'), 'EXISTING')
173
- const { stdout } = await run(['--to', target, '--dht-bootstrap', bootstrapArg(testnet), link])
174
- t.ok(stdout.includes('Refusing to overwrite existing'), 'refusing message printed')
175
- t.ok(stdout.includes('Manually remove first'), 'fix hint printed')
176
- }
177
- )
178
-
179
- test('_move falls back to copy+rm on EXDEV', { skip: isWindows }, async function (t) {
180
- t.timeout(60000)
181
- const testnet = await createTestnet(3, t.teardown)
182
- const key = await seed(t, {
183
- bootstrap: testnet.bootstrap,
184
- manifest: { name: 'tbin', version: '1.0.0', upgrade: 'pear://x', bin: 'cli.js' },
185
- files: { ['/by-arch/' + arch + '/app/tbin']: 'BIN' }
186
- })
187
- const link = plink.serialize({ drive: { key } })
188
- const target = await tmp(t)
189
- const renameSync = fs.renameSync
190
- fs.renameSync = function () {
191
- const err = new Error('cross-device link not permitted')
192
- err.code = 'EXDEV'
193
- throw err
194
- }
195
- t.teardown(() => {
196
- fs.renameSync = renameSync
197
- })
198
- const { events } = await run([
199
- '--json',
200
- '--to',
201
- target,
202
- '--dht-bootstrap',
203
- bootstrapArg(testnet),
204
- link
205
- ])
206
- const final = events.find((e) => e.tag === 'final')
207
- t.is(final?.data?.success, true, 'install succeeded via copy+rm fallback')
208
- t.is(fs.readFileSync(path.join(target, 'tbin'), 'utf8'), 'BIN', 'bin content copied to target')
209
- })
210
-
211
- test('permission denied when target dir is read-only', { skip: isWindows }, async function (t) {
212
- t.timeout(60000)
213
- const testnet = await createTestnet(3, t.teardown)
214
- const key = await seed(t, {
215
- bootstrap: testnet.bootstrap,
216
- manifest: { name: 'tbin', version: '1.0.0', upgrade: 'pear://x', bin: 'cli.js' },
217
- files: { ['/by-arch/' + arch + '/app/tbin']: 'BIN' }
218
- })
219
- const link = plink.serialize({ drive: { key } })
220
- const target = await tmp(t)
221
- fs.chmodSync(target, 0o555)
222
- t.teardown(() => {
223
- try {
224
- fs.chmodSync(target, 0o755)
225
- } catch {}
226
- })
227
- const { events, stdout } = await run([
228
- '--to',
229
- target,
230
- '--dht-bootstrap',
231
- bootstrapArg(testnet),
232
- link
233
- ])
234
- // since no --json, events is empty; assert via stdout
235
- t.is(events.length, 0, 'no json events (non-json mode)')
236
- t.ok(stdout.includes('Permission denied'), 'permission message printed')
237
- t.ok(stdout.includes('Fix:'), 'fix hint printed')
238
- })