restorm-cli 1.0.1 → 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 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 installer bootstrap.
3
+ * Restorm launcher.
4
4
  *
5
- * If Restorm is already installed, exec it (all arguments pass through, so
6
- * `restorm --run scenario.restorm` works). Otherwise, detect the platform,
7
- * download the right build from the official release bucket, and guide the
8
- * install. Zero dependencies.
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, execFileSync } = require('node:child_process')
13
- const { createWriteStream, existsSync, mkdirSync, chmodSync } = require('node:fs')
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 BASE = 'https://dl.restorm.app/latest'
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 artifact() {
36
- const { platform, arch } = process
37
- if (platform === 'linux') return { url: `${BASE}/Restorm.AppImage`, file: 'Restorm.AppImage', run: true }
38
- if (platform === 'darwin') {
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
- async function download(url, dest) {
48
- const res = await fetch(url, { redirect: 'follow' })
49
- if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`)
50
- const total = Number(res.headers.get('content-length') || 0)
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 installed = findInstalled()
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 a = artifact()
75
- if (!a) {
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
- console.log('Restorm is not installed yet fetching the latest build…')
82
- const dir = join(homedir(), 'Downloads')
83
- const dest = join(existsSync(dir) ? dir : tmpdir(), a.file)
84
- await download(a.url, dest)
85
- console.log(`Saved to ${dest}`)
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
- if (a.run && process.platform === 'linux') {
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.1",
4
- "description": "Restorm — local-first API testing environment (REST, GraphQL, gRPC, WebSocket, MQTT, Redis, AMQP…). This package launches your installed Restorm or bootstraps the right build for your platform.",
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"