restorm-cli 1.0.0 → 1.0.2
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 +36 -0
- package/bin/restorm.js +35 -66
- package/lib/install.js +223 -0
- package/lib/resolve.js +143 -0
- package/package.json +9 -4
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# restorm-cli
|
|
2
|
+
|
|
3
|
+
Run a [Restorm](https://restorm.app) scenario from a terminal, or from CI where
|
|
4
|
+
there is no terminal to click in.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install -g restorm-cli
|
|
8
|
+
restorm --open ./api.restorm --run "Smoke tests" --headless
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Your token
|
|
12
|
+
|
|
13
|
+
A headless run authenticates with an organization token (prefixed `rstk_`) read
|
|
14
|
+
from `RESTORM_TOKEN`.
|
|
15
|
+
|
|
16
|
+
Create one **inside Restorm**: the account button in the title bar, then *My
|
|
17
|
+
CLI/CI tokens*. Give it a label and a lifetime, and copy it — the value is shown
|
|
18
|
+
once, at creation, and is never retrievable afterwards, including by us. You can
|
|
19
|
+
revoke it at any time, and a revoked or expired token stops working immediately.
|
|
20
|
+
|
|
21
|
+
Tokens can also be managed from your account dashboard at
|
|
22
|
+
[my.restorm.app](https://my.restorm.app).
|
|
23
|
+
|
|
24
|
+
→ [Accounts, sign-in and CLI/CI tokens](https://restorm.app/en/docs/entreprise/comptes-et-connexion/)
|
|
25
|
+
|
|
26
|
+
## In a pipeline
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
- name: API smoke tests
|
|
30
|
+
run: npx restorm-cli --open ./api.restorm --run "Smoke tests" --headless
|
|
31
|
+
env:
|
|
32
|
+
RESTORM_TOKEN: ${{ secrets.RESTORM_TOKEN }}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
→ [Headless execution and CI](https://restorm.app/en/docs/scenarios/execution-headless-ci/)
|
|
36
|
+
· [All CLI arguments and output formats](https://restorm.app/en/docs/reference/cli/)
|
package/bin/restorm.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Restorm
|
|
3
|
+
* Restorm launcher.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* `restorm --
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* `restorm …` runs Restorm and passes every argument through, so
|
|
6
|
+
* `restorm --open api.restorm --run "Smoke tests" --headless` behaves exactly as
|
|
7
|
+
* it does on a desktop install. The application itself is not in this package —
|
|
8
|
+
* it is resolved, in order:
|
|
9
|
+
*
|
|
10
|
+
* 1. a system installation, if the machine has one (a user who installed the
|
|
11
|
+
* .deb, the .dmg or the Windows installer expects THAT build to run);
|
|
12
|
+
* 2. the per-user cache, from a previous run;
|
|
13
|
+
* 3. a fresh download of the official build, verified against the checksum
|
|
14
|
+
* published in the release manifest, then cached.
|
|
15
|
+
*
|
|
16
|
+
* Step 3 happens once per version. A CI job that runs this twice downloads
|
|
17
|
+
* once. Zero dependencies.
|
|
9
18
|
*/
|
|
10
19
|
'use strict'
|
|
11
20
|
|
|
12
|
-
const { spawnSync
|
|
13
|
-
const {
|
|
21
|
+
const { spawnSync } = require('node:child_process')
|
|
22
|
+
const { existsSync } = require('node:fs')
|
|
14
23
|
const { join } = require('node:path')
|
|
15
|
-
const { homedir, tmpdir } = require('node:os')
|
|
16
24
|
|
|
17
|
-
const
|
|
25
|
+
const { ensureBuild } = require('../lib/install')
|
|
18
26
|
|
|
19
27
|
const CANDIDATES = {
|
|
20
28
|
linux: ['/opt/Restorm/restorm-bin', '/snap/bin/restorm', '/usr/bin/restorm'],
|
|
@@ -32,71 +40,32 @@ function findInstalled() {
|
|
|
32
40
|
return null
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
function
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
return arch === 'arm64'
|
|
40
|
-
? { url: `${BASE}/Restorm-arm64.dmg`, file: 'Restorm-arm64.dmg' }
|
|
41
|
-
: { url: `${BASE}/Restorm.dmg`, file: 'Restorm.dmg' }
|
|
42
|
-
}
|
|
43
|
-
if (platform === 'win32') return { url: `${BASE}/Restorm-Setup.exe`, file: 'Restorm-Setup.exe' }
|
|
44
|
-
return null
|
|
43
|
+
function run(exe, args) {
|
|
44
|
+
const r = spawnSync(exe, args, { stdio: 'inherit' })
|
|
45
|
+
if (r.error) throw r.error
|
|
46
|
+
process.exit(r.status ?? 0)
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
let done = 0
|
|
52
|
-
const out = createWriteStream(dest)
|
|
53
|
-
const reader = res.body.getReader()
|
|
54
|
-
for (;;) {
|
|
55
|
-
const { value, done: end } = await reader.read()
|
|
56
|
-
if (end) break
|
|
57
|
-
out.write(Buffer.from(value))
|
|
58
|
-
done += value.length
|
|
59
|
-
if (total && process.stdout.isTTY) {
|
|
60
|
-
process.stdout.write(`\r ${(done / 1048576).toFixed(1)} / ${(total / 1048576).toFixed(1)} MiB`)
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())))
|
|
64
|
-
if (process.stdout.isTTY) process.stdout.write('\n')
|
|
49
|
+
function progress(done, total) {
|
|
50
|
+
if (!process.stdout.isTTY) return
|
|
51
|
+
const mib = (n) => (n / 1048576).toFixed(1)
|
|
52
|
+
process.stdout.write(total ? `\r ${mib(done)} / ${mib(total)} MiB` : `\r ${mib(done)} MiB`)
|
|
65
53
|
}
|
|
66
54
|
|
|
67
55
|
async function main() {
|
|
68
|
-
const
|
|
69
|
-
if (installed) {
|
|
70
|
-
const r = spawnSync(installed, process.argv.slice(2), { stdio: 'inherit' })
|
|
71
|
-
process.exit(r.status ?? 0)
|
|
72
|
-
}
|
|
56
|
+
const args = process.argv.slice(2)
|
|
73
57
|
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
console.error(`Unsupported platform: ${process.platform}/${process.arch}`)
|
|
77
|
-
console.error('Downloads for every platform: https://restorm.app/download')
|
|
78
|
-
process.exit(1)
|
|
79
|
-
}
|
|
58
|
+
const installed = findInstalled()
|
|
59
|
+
if (installed) run(installed, args)
|
|
80
60
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
61
|
+
const { exe, version, cached } = await ensureBuild({
|
|
62
|
+
log: (m) => console.error(m),
|
|
63
|
+
onProgress: progress
|
|
64
|
+
})
|
|
65
|
+
if (!cached && process.stdout.isTTY) process.stdout.write('\n')
|
|
66
|
+
if (!cached) console.error(`Restorm ${version} is ready.`)
|
|
86
67
|
|
|
87
|
-
|
|
88
|
-
chmodSync(dest, 0o755)
|
|
89
|
-
console.log('Launching the AppImage… (prefer a package? https://restorm.app/download)')
|
|
90
|
-
const r = spawnSync(dest, process.argv.slice(2), { stdio: 'inherit' })
|
|
91
|
-
process.exit(r.status ?? 0)
|
|
92
|
-
}
|
|
93
|
-
if (process.platform === 'darwin') {
|
|
94
|
-
console.log('Opening the disk image — drag Restorm to Applications, then run `restorm` again.')
|
|
95
|
-
try { execFileSync('open', [dest]) } catch {}
|
|
96
|
-
} else if (process.platform === 'win32') {
|
|
97
|
-
console.log('Launching the installer — run `restorm` again once it finishes.')
|
|
98
|
-
try { execFileSync('cmd', ['/c', 'start', '', dest]) } catch {}
|
|
99
|
-
}
|
|
68
|
+
run(exe, args)
|
|
100
69
|
}
|
|
101
70
|
|
|
102
71
|
main().catch((e) => {
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch, verify and cache the Restorm build this machine needs.
|
|
5
|
+
*
|
|
6
|
+
* The contract every caller depends on: what this function returns is either a
|
|
7
|
+
* complete, checksum-verified executable, or an exception. A half-written
|
|
8
|
+
* download is never handed back and never left where a later run could mistake
|
|
9
|
+
* it for a good one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { spawnSync } = require('node:child_process')
|
|
13
|
+
const { createHash } = require('node:crypto')
|
|
14
|
+
const {
|
|
15
|
+
chmodSync,
|
|
16
|
+
createWriteStream,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statSync,
|
|
23
|
+
writeFileSync
|
|
24
|
+
} = require('node:fs')
|
|
25
|
+
const { homedir } = require('node:os')
|
|
26
|
+
const { join } = require('node:path')
|
|
27
|
+
|
|
28
|
+
const resolve = require('./resolve')
|
|
29
|
+
|
|
30
|
+
const COMPLETE_MARKER = '.complete'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Asks for this package's own version first, and falls back to the newest
|
|
34
|
+
* release if that manifest is not there — which happens for exactly one reason
|
|
35
|
+
* worth surviving: the npm package published before, or without, the matching
|
|
36
|
+
* bucket upload.
|
|
37
|
+
*/
|
|
38
|
+
async function fetchManifest(env, platform, fetchImpl, version) {
|
|
39
|
+
const call = fetchImpl || fetch
|
|
40
|
+
const pinned = resolve.manifestUrl(env, platform, version)
|
|
41
|
+
const res = await call(pinned, { redirect: 'follow' })
|
|
42
|
+
if (res.ok) return await res.json()
|
|
43
|
+
|
|
44
|
+
const newest = resolve.manifestUrl(env, platform, 'latest')
|
|
45
|
+
if (res.status !== 404 || pinned === newest || env.RESTORM_VERSION) {
|
|
46
|
+
throw new Error(`release manifest unavailable: HTTP ${res.status} for ${pinned}`)
|
|
47
|
+
}
|
|
48
|
+
const fallback = await call(newest, { redirect: 'follow' })
|
|
49
|
+
if (!fallback.ok) {
|
|
50
|
+
throw new Error(`release manifest unavailable: HTTP ${fallback.status} for ${newest}`)
|
|
51
|
+
}
|
|
52
|
+
return await fallback.json()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Streams to disk while hashing, then compares. Hashing the stream rather than
|
|
57
|
+
* re-reading the file afterwards keeps a 400 MB artifact off a second pass, and
|
|
58
|
+
* — more importantly — leaves no window where the file on disk is not the file
|
|
59
|
+
* that was verified.
|
|
60
|
+
*/
|
|
61
|
+
async function downloadVerified(url, dest, sha256, { fetchImpl, onProgress } = {}) {
|
|
62
|
+
const res = await (fetchImpl || fetch)(url, { redirect: 'follow' })
|
|
63
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`)
|
|
64
|
+
const total = Number(res.headers.get('content-length') || 0)
|
|
65
|
+
const hash = createHash('sha256')
|
|
66
|
+
const out = createWriteStream(dest)
|
|
67
|
+
const reader = res.body.getReader()
|
|
68
|
+
let done = 0
|
|
69
|
+
for (;;) {
|
|
70
|
+
const { value, done: end } = await reader.read()
|
|
71
|
+
if (end) break
|
|
72
|
+
const chunk = Buffer.from(value)
|
|
73
|
+
hash.update(chunk)
|
|
74
|
+
out.write(chunk)
|
|
75
|
+
done += chunk.length
|
|
76
|
+
if (onProgress) onProgress(done, total)
|
|
77
|
+
}
|
|
78
|
+
await new Promise((ok, ko) => out.end((e) => (e ? ko(e) : ok())))
|
|
79
|
+
const actual = hash.digest('hex')
|
|
80
|
+
if (actual !== sha256) {
|
|
81
|
+
rmSync(dest, { force: true })
|
|
82
|
+
throw new Error(
|
|
83
|
+
`checksum mismatch for ${url}\n expected ${sha256}\n got ${actual}\n` +
|
|
84
|
+
'The download was discarded rather than executed.'
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
return actual
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractArchive(platform, archive, dir) {
|
|
91
|
+
const candidates = resolve.extractCommands(platform, archive, dir)
|
|
92
|
+
if (candidates.length === 0) throw new Error(`no extractor known for ${platform}`)
|
|
93
|
+
let last = null
|
|
94
|
+
for (const { cmd, args } of candidates) {
|
|
95
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit' })
|
|
96
|
+
if (!r.error && r.status === 0) return cmd
|
|
97
|
+
last = r.error ? r.error.message : `${cmd} exited ${r.status}`
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`could not unpack ${archive}: ${last}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Deletes every cached version but the one in use and the most recent other.
|
|
104
|
+
* Never fatal: a cache we failed to tidy is a disk-space problem, not a reason
|
|
105
|
+
* to refuse to run.
|
|
106
|
+
*/
|
|
107
|
+
function prune(root, keepVersion, log) {
|
|
108
|
+
let entries
|
|
109
|
+
try {
|
|
110
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
111
|
+
} catch {
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
const versions = entries
|
|
115
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
116
|
+
.map((e) => {
|
|
117
|
+
try {
|
|
118
|
+
return { name: e.name, mtimeMs: statSync(join(root, e.name)).mtimeMs }
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
const victims = resolve.pruneVictims(versions, keepVersion)
|
|
125
|
+
for (const name of victims) {
|
|
126
|
+
try {
|
|
127
|
+
rmSync(join(root, name), { recursive: true, force: true })
|
|
128
|
+
if (log) log(`Removed cached build ${name}`)
|
|
129
|
+
} catch {
|
|
130
|
+
/* a build still running from this directory keeps it; try again next time */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return victims
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function ensureBuild(options = {}) {
|
|
137
|
+
const {
|
|
138
|
+
env = process.env,
|
|
139
|
+
platform = process.platform,
|
|
140
|
+
arch = process.arch,
|
|
141
|
+
home = homedir(),
|
|
142
|
+
fetchImpl,
|
|
143
|
+
log = () => {},
|
|
144
|
+
onProgress,
|
|
145
|
+
ownVersion = require('../package.json').version
|
|
146
|
+
} = options
|
|
147
|
+
|
|
148
|
+
const manifest = await fetchManifest(env, platform, fetchImpl, ownVersion)
|
|
149
|
+
const version = manifest && manifest.version
|
|
150
|
+
if (!version) throw new Error('the release manifest carries no version')
|
|
151
|
+
|
|
152
|
+
const artifact = resolve.pickArtifact(manifest, platform, arch)
|
|
153
|
+
if (!artifact) {
|
|
154
|
+
throw new Error(`no Restorm build is published for ${platform}-${arch}`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const root = resolve.cacheRoot(env, platform, home)
|
|
158
|
+
const versionDir = join(root, version)
|
|
159
|
+
const dir = join(versionDir, resolve.platformKey(platform, arch))
|
|
160
|
+
const exe = join(dir, artifact.exec)
|
|
161
|
+
|
|
162
|
+
if (existsSync(join(dir, COMPLETE_MARKER)) && existsSync(exe)) {
|
|
163
|
+
return { exe, version, cached: true }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Everything below happens under a private staging directory, so two runs
|
|
167
|
+
// racing on the same cache (two CI jobs on one machine) cannot see each
|
|
168
|
+
// other's partial work. The loser of the race throws its copy away.
|
|
169
|
+
const stage = join(root, `.staging-${process.pid}-${platform}-${arch}`)
|
|
170
|
+
rmSync(stage, { recursive: true, force: true })
|
|
171
|
+
mkdirSync(stage, { recursive: true })
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const archive = join(stage, artifact.file)
|
|
175
|
+
log(`Fetching Restorm ${version} (${artifact.kind === 'appimage' ? 'AppImage' : 'archive'})…`)
|
|
176
|
+
await downloadVerified(artifact.url, archive, artifact.sha256, { fetchImpl, onProgress })
|
|
177
|
+
|
|
178
|
+
const payload = join(stage, 'payload')
|
|
179
|
+
mkdirSync(payload, { recursive: true })
|
|
180
|
+
if (artifact.kind === 'appimage') {
|
|
181
|
+
renameSync(archive, join(payload, artifact.exec))
|
|
182
|
+
chmodSync(join(payload, artifact.exec), 0o755)
|
|
183
|
+
} else {
|
|
184
|
+
extractArchive(platform, archive, payload)
|
|
185
|
+
rmSync(archive, { force: true })
|
|
186
|
+
const staged = join(payload, artifact.exec)
|
|
187
|
+
if (!existsSync(staged)) {
|
|
188
|
+
throw new Error(`the archive did not contain ${artifact.exec}`)
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
chmodSync(staged, 0o755)
|
|
192
|
+
} catch {
|
|
193
|
+
/* Windows has no execute bit */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The marker goes in before the directory is published, so the name a
|
|
198
|
+
// later run tests for can only appear on a finished install.
|
|
199
|
+
writeFileSync(join(payload, COMPLETE_MARKER), `${version}\n`)
|
|
200
|
+
mkdirSync(versionDir, { recursive: true })
|
|
201
|
+
try {
|
|
202
|
+
renameSync(payload, dir)
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// Another process published the same version first — theirs is as good
|
|
205
|
+
// as ours, and verified the same way.
|
|
206
|
+
if (!existsSync(exe)) throw e
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
rmSync(stage, { recursive: true, force: true })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
prune(root, version, log)
|
|
213
|
+
return { exe, version, cached: false }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
COMPLETE_MARKER,
|
|
218
|
+
downloadVerified,
|
|
219
|
+
ensureBuild,
|
|
220
|
+
extractArchive,
|
|
221
|
+
fetchManifest,
|
|
222
|
+
prune
|
|
223
|
+
}
|
package/lib/resolve.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure resolution helpers for the launcher: where the cache lives, which
|
|
5
|
+
* artifact this machine needs, how to unpack it. No I/O happens here, so every
|
|
6
|
+
* rule below is testable on any platform regardless of the one it describes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { join } = require('node:path')
|
|
10
|
+
|
|
11
|
+
const DEFAULT_BASE = 'https://dl.restorm.app'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the runnable binary sits once the artifact is unpacked. The manifest
|
|
15
|
+
* carries this per platform; these are the fallbacks for a manifest written
|
|
16
|
+
* before the field existed.
|
|
17
|
+
*/
|
|
18
|
+
const EXEC_BY_PLATFORM = {
|
|
19
|
+
linux: 'Restorm.AppImage',
|
|
20
|
+
darwin: 'Restorm.app/Contents/MacOS/Restorm',
|
|
21
|
+
win32: 'Restorm.exe'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function platformKey(platform, arch) {
|
|
25
|
+
return `${platform}-${arch}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function downloadBase(env) {
|
|
29
|
+
return String(env.RESTORM_DL_BASE || DEFAULT_BASE).replace(/\/+$/, '')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One manifest per platform rather than a single merged one: the release
|
|
34
|
+
* workflow publishes from inside its build matrix, so no job ever holds all
|
|
35
|
+
* three platforms' artifacts at once. A per-platform file lets each job write
|
|
36
|
+
* its own without coordinating with the others.
|
|
37
|
+
*
|
|
38
|
+
* The version asked for is THIS package's own, because the two are published
|
|
39
|
+
* together and always match. It matters in CI: `restorm-cli@1.0.2` must run
|
|
40
|
+
* Restorm 1.0.2 six months from now, not whatever shipped since — a pinned
|
|
41
|
+
* dependency that silently follows a moving target is not pinned at all.
|
|
42
|
+
* `RESTORM_VERSION` overrides it, and `RESTORM_VERSION=latest` opts back into
|
|
43
|
+
* the newest release.
|
|
44
|
+
*/
|
|
45
|
+
function manifestUrl(env, platform, version) {
|
|
46
|
+
const base = downloadBase(env)
|
|
47
|
+
const want = env.RESTORM_VERSION || version
|
|
48
|
+
if (!want || want === 'latest' || String(want).startsWith('0.0.0')) {
|
|
49
|
+
return `${base}/latest/manifest-${platform}.json`
|
|
50
|
+
}
|
|
51
|
+
return `${base}/v${want}/manifest-${platform}.json`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The per-user cache. Each OS has a directory the system already understands as
|
|
56
|
+
* "regenerable data" — a cleaner may empty it, and the next run simply
|
|
57
|
+
* downloads again.
|
|
58
|
+
*/
|
|
59
|
+
function cacheRoot(env, platform, home) {
|
|
60
|
+
if (env.RESTORM_CACHE_DIR) return env.RESTORM_CACHE_DIR
|
|
61
|
+
if (platform === 'win32') {
|
|
62
|
+
return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'restorm', 'Cache')
|
|
63
|
+
}
|
|
64
|
+
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'restorm')
|
|
65
|
+
return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'restorm')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pickArtifact(manifest, platform, arch) {
|
|
69
|
+
const key = platformKey(platform, arch)
|
|
70
|
+
const entry = manifest && manifest.artifacts && manifest.artifacts[key]
|
|
71
|
+
if (!entry) return null
|
|
72
|
+
if (!entry.url || !entry.sha256) {
|
|
73
|
+
throw new Error(`the release manifest's ${key} entry has no url or no sha256`)
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
kind: entry.kind || 'zip',
|
|
77
|
+
url: entry.url,
|
|
78
|
+
sha256: String(entry.sha256).toLowerCase(),
|
|
79
|
+
size: Number(entry.size) || 0,
|
|
80
|
+
file: entry.file || entry.url.split('/').pop(),
|
|
81
|
+
exec: entry.exec || EXEC_BY_PLATFORM[platform]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Unpacking is delegated to a tool every supported OS ships with, so the
|
|
87
|
+
* package keeps its zero dependencies.
|
|
88
|
+
*
|
|
89
|
+
* macOS gets `ditto` rather than `unzip`: a `.app` is a tree of symlinks and
|
|
90
|
+
* extended attributes, and ditto is the only extractor Apple guarantees will
|
|
91
|
+
* reproduce it faithfully. Windows gets bsdtar (`tar` since Windows 10), which
|
|
92
|
+
* reads zip and is an order of magnitude faster than Expand-Archive on a
|
|
93
|
+
* 400 MB archive — the fallback exists for older images.
|
|
94
|
+
*/
|
|
95
|
+
function extractCommands(platform, archive, dir) {
|
|
96
|
+
if (platform === 'darwin') {
|
|
97
|
+
return [
|
|
98
|
+
{ cmd: 'ditto', args: ['-x', '-k', archive, dir] },
|
|
99
|
+
{ cmd: 'unzip', args: ['-q', archive, '-d', dir] }
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
if (platform === 'win32') {
|
|
103
|
+
return [
|
|
104
|
+
{ cmd: 'tar', args: ['-xf', archive, '-C', dir] },
|
|
105
|
+
{
|
|
106
|
+
cmd: 'powershell',
|
|
107
|
+
args: [
|
|
108
|
+
'-NoProfile',
|
|
109
|
+
'-NonInteractive',
|
|
110
|
+
'-Command',
|
|
111
|
+
`Expand-Archive -LiteralPath ${JSON.stringify(archive)} -DestinationPath ${JSON.stringify(dir)} -Force`
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Which cached versions to delete once `keepVersion` is in place. Keeping one
|
|
121
|
+
* previous version costs disk but saves the whole download when a machine
|
|
122
|
+
* alternates between a released and a preview build, or when a release is
|
|
123
|
+
* rolled back.
|
|
124
|
+
*/
|
|
125
|
+
function pruneVictims(versions, keepVersion, keep = 2) {
|
|
126
|
+
return versions
|
|
127
|
+
.filter((v) => v.name !== keepVersion)
|
|
128
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
129
|
+
.slice(Math.max(0, keep - 1))
|
|
130
|
+
.map((v) => v.name)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
DEFAULT_BASE,
|
|
135
|
+
EXEC_BY_PLATFORM,
|
|
136
|
+
cacheRoot,
|
|
137
|
+
downloadBase,
|
|
138
|
+
extractCommands,
|
|
139
|
+
manifestUrl,
|
|
140
|
+
pickArtifact,
|
|
141
|
+
platformKey,
|
|
142
|
+
pruneVictims
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "restorm-cli",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Restorm — local-first API testing environment (REST, GraphQL, gRPC, WebSocket, MQTT, Redis, AMQP…).
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Restorm — local-first API testing environment (REST, GraphQL, gRPC, WebSocket, MQTT, Redis, AMQP…). Runs Restorm from a terminal or from CI: your installation if you have one, otherwise the official build, downloaded once, checksum-verified and cached.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"restorm": "bin/restorm.js"
|
|
7
7
|
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node --test \"test/*.test.js\""
|
|
10
|
+
},
|
|
8
11
|
"files": [
|
|
9
|
-
"bin"
|
|
12
|
+
"bin",
|
|
13
|
+
"lib",
|
|
14
|
+
"README.md"
|
|
10
15
|
],
|
|
11
16
|
"engines": {
|
|
12
17
|
"node": ">=18"
|
|
@@ -25,4 +30,4 @@
|
|
|
25
30
|
"local-first"
|
|
26
31
|
],
|
|
27
32
|
"author": "MonsieurDev <contact@restorm.app>"
|
|
28
|
-
}
|
|
33
|
+
}
|