@neosmart/ssclient 2.0.0
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 +99 -0
- package/bin/ssclient.cmd +1 -0
- package/bin/ssclient.wasm +0 -0
- package/build.js +88 -0
- package/install.js +337 -0
- package/package.json +51 -0
- package/ssclient.js +100 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# `ssclient`, the SecureStore CLI
|
|
2
|
+
|
|
3
|
+
This npmjs package contains a universal binary installer for [`ssclient`](https://github.com/neosmart/securestore-rs/tree/master/ssclient) the rust cli companion to the open source, cross-platform, language-agnostic [SecureStore secrets management protocol](https://neosmart.net/SecureStore/).
|
|
4
|
+
|
|
5
|
+
This package will attempt to automatically download and install precompiled, dependency-free binaries for your os/architecture via the npmjs package repos. Please refer to the [`ssclient` documentation](https://github.com/neosmart/securestore-rs/tree/master/ssclient) for more information on the use of `ssclient` to create and manage truly secure secrets containers and help put an end to `.env` madness and insecurity.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
This package is compatible with `npm` and `bun`, and can be installed or run directly with with `npx`/`bunx`.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install [--global] @neosmart/ssclient
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
or
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bun --global add @neosmart/ssclient
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Will attempt to locate, download, and install a precompiled, dependency-free `ssclient` binary for your current platform/architecture. If no such precompiled binary currently exists, a WASM-compiled version of the binary [will be deployed instead](https://neosmart.net/blog/ssclient-wasm).
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Please refer to the [`ssclient` documentation](https://github.com/neosmart/securestore-rs/tree/master/ssclient) for full usage information, but the following is a brief guide on creating and access secrets with the `ssclient` cli:
|
|
26
|
+
|
|
27
|
+
Note that you may substitute `npx @neosmart/ssclient` or `bunx @neosmart/ssclient` for `ssclient` in the steps below if you have not globally installed `ssclient` via whatever means you prefer.
|
|
28
|
+
|
|
29
|
+
### Creating a new secrets container
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
~> mkdir secrets/
|
|
33
|
+
~> cd secrets/
|
|
34
|
+
~> ssclient create --export-key secrets.key
|
|
35
|
+
Password: ************
|
|
36
|
+
Confirm Password: ************
|
|
37
|
+
|
|
38
|
+
# Now you can use `ssclient -p` with your old password
|
|
39
|
+
# or `ssclient -k secrets.key` to encrypt/decrypt with
|
|
40
|
+
# the same keys.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Adding secrets
|
|
44
|
+
|
|
45
|
+
Secrets may be added with your password or the equivalent encryption key file, and may be specified in-line as arguments to `ssclient` or more securely at a prompt by omitting the value when calling `ssclient create`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# ssclient defaults to password-based decryption:
|
|
49
|
+
~> ssclient set aws:s3:accessId AKIAV4EXAMPLE7QWERT
|
|
50
|
+
Password: *********
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
similarly:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Use `-k secrets.key` to load the encryption key and
|
|
57
|
+
# skip the prompt for the vault password:
|
|
58
|
+
~> ssclient -k secrets.key set aws:s3:accessKey
|
|
59
|
+
Value: v1Lp9X7mN2B5vR8zQ4tW1eY6uI0oP3aS5dF7gH9j
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Note that in the latter example, the secret being stored was not typed into the shell (bash, fish, etc) and, as such, is not contained in/leaked by the shell history.
|
|
63
|
+
|
|
64
|
+
### Retrieving secrets
|
|
65
|
+
|
|
66
|
+
While you normally use one of the [SecureStore libraries for your language of choice](https://neosmart.net/SecureStore/) to load the SecureStore vault and decrypt secrets with the exported encryption/decryption key (`secrets.key`, in the example above), you can also use `ssclient` to view them at the command line:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
~> ssclient get aws:s3:accessKey
|
|
70
|
+
Password: *********
|
|
71
|
+
v1Lp9X7mN2B5vR8zQ4tW1eY6uI0oP3aS5dF7gH9j
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
or you can export all secrets, either as text or json (again, either with the password or with the equivalent private key).
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
~> ssclient get --all # defaults to JSON
|
|
78
|
+
Password: *********
|
|
79
|
+
[
|
|
80
|
+
{
|
|
81
|
+
"key": "aws:s3:accessId",
|
|
82
|
+
"value": "AKIAV4EXAMPLE7QWERT"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"key": "aws:s3:accessKey",
|
|
86
|
+
"value": "v1Lp9X7mN2B5vR8zQ4tW1eY6uI0oP3aS5dF7gH9j"
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
or
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
~> ssclient get --key secrets.key -a --format text
|
|
95
|
+
aws:s3:accessId:AKIAV4EXAMPLE7QWERT
|
|
96
|
+
aws:s3:accessKey:v1Lp9X7mN2B5vR8zQ4tW1eY6uI0oP3aS5dF7gH9j
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Please refer to the [`ssclient` documentation](https://github.com/neosmart/securestore-rs/tree/master/ssclient) for full usage information.
|
package/bin/ssclient.cmd
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This placeholder file will be overridden by our install scripts
|
|
Binary file
|
package/build.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// @ts-check
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
const cargoRoot = path.resolve(__dirname, '..', '..');
|
|
14
|
+
const srcWasm = path.resolve(cargoRoot, 'target/wasm32-wasip1/release/ssclient.wasm');
|
|
15
|
+
const dstDir = path.resolve(__dirname, 'bin');
|
|
16
|
+
const dstWasm = path.resolve(dstDir, 'ssclient.wasm');
|
|
17
|
+
const ssclient = path.resolve(__dirname, 'ssclient.js');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Helper to exit with a non-zero status code
|
|
21
|
+
* @param {string} message
|
|
22
|
+
*/
|
|
23
|
+
const die = (message) => {
|
|
24
|
+
console.error(`[BUILD ERROR] ${message}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
console.log(`> cd ${cargoRoot}`);
|
|
29
|
+
|
|
30
|
+
/** @type {import('node:child_process').SpawnSyncOptions} */
|
|
31
|
+
const spawnOptions = {
|
|
32
|
+
cwd: cargoRoot,
|
|
33
|
+
stdio: 'inherit',
|
|
34
|
+
env: {
|
|
35
|
+
...process.env,
|
|
36
|
+
RUSTFLAGS: '', // Explicitly clear RUSTFLAGS
|
|
37
|
+
},
|
|
38
|
+
shell: false
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const cargoArgs = [
|
|
42
|
+
'build',
|
|
43
|
+
'--no-default-features',
|
|
44
|
+
'--features', 'rustls',
|
|
45
|
+
'--target', 'wasm32-wasip1',
|
|
46
|
+
'--release'
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
console.log(`> cargo ${cargoArgs.join(' ')}`);
|
|
50
|
+
|
|
51
|
+
const result = spawnSync('cargo', cargoArgs, spawnOptions);
|
|
52
|
+
|
|
53
|
+
if (result.error) {
|
|
54
|
+
die(`Failed to execute cargo: ${result.error.message}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (result.status !== 0) {
|
|
58
|
+
die(`Cargo build failed with exit code ${result.status}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
if (!fs.existsSync(dstDir)) {
|
|
63
|
+
console.log(`Creating directory: ${dstDir}`);
|
|
64
|
+
fs.mkdirSync(dstDir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(`Copying build artifact:\n From: ${srcWasm}\n To: ${dstWasm}`);
|
|
68
|
+
|
|
69
|
+
if (!fs.existsSync(srcWasm)) {
|
|
70
|
+
die(`Source file not found at ${srcWasm}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fs.copyFileSync(srcWasm, dstWasm);
|
|
74
|
+
console.log('Build and copy successful.');
|
|
75
|
+
} catch (err) {
|
|
76
|
+
/** @type {Error} */
|
|
77
|
+
// @ts-ignore
|
|
78
|
+
const error = err;
|
|
79
|
+
die(`File operation failed: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Ensure ssclient.js runs ok
|
|
83
|
+
const wasmResult = spawnSync('node', [ssclient, "--version"]);
|
|
84
|
+
if (wasmResult.error) {
|
|
85
|
+
die(`Failed to execute ssclient.wasm (via ssclient.js): ${wasmResult.error.message}`);
|
|
86
|
+
} else if (wasmResult.status !== 0) {
|
|
87
|
+
die(`ssclient.wasm failed with exit code ${wasmResult.status}`);
|
|
88
|
+
}
|
package/install.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// @ts-check
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
import { mkdir, rm, rename, unlink, symlink, copyFile, chmod, readdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
8
|
+
import { finished } from 'node:stream/promises';
|
|
9
|
+
import { join, relative, basename, dirname } from "node:path";
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import pkg from "./package.json" with { type: "json" };
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = dirname(__filename);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} BuildEntry
|
|
21
|
+
* @property {string | string[]} url - One or more URLs to try
|
|
22
|
+
* @property {string} [bin_path] - Override default bin_path
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} Manifest
|
|
27
|
+
* @property {string} bin_path - Default path to entrypoint within archives
|
|
28
|
+
* @property {Record<string, string | string[] | BuildEntry>} precompiled - Mapping of os-arch to builds
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const MANIFEST_URLS = [
|
|
32
|
+
`${__dirname}/manifest.json`,
|
|
33
|
+
`https://raw.githubusercontent.com/neosmart/securestore-rs/refs/heads/master/ssclient/npm/manifests/v${pkg.version}.json`,
|
|
34
|
+
`https://neosmart.net/SecureStore/ssclient/npm/manifests/v${pkg.version}.json`,
|
|
35
|
+
`https://raw.githubusercontent.com/neosmart/securestore-rs/refs/tags/ssclient/${pkg.version}/ssclient/npm/manifests/v${pkg.version}.json`,
|
|
36
|
+
];
|
|
37
|
+
const BIN_NAME = "ssclient";
|
|
38
|
+
const PKG_ROOT = __dirname;
|
|
39
|
+
const FALLBACK_JS = join(PKG_ROOT, "ssclient.js");
|
|
40
|
+
const BASE_BIN_DIR = join(PKG_ROOT, "bin");
|
|
41
|
+
const VERSION = pkg.version;
|
|
42
|
+
const VERSIONED_DIR = join(BASE_BIN_DIR, `v${VERSION}`);
|
|
43
|
+
const ENTRY_POINT = join(__dirname, pkg.bin[BIN_NAME]);
|
|
44
|
+
|
|
45
|
+
/** @returns {string} */
|
|
46
|
+
function getPlatformTuple() {
|
|
47
|
+
return `${process.platform}-${process.arch}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string} urlStr @returns "string" */
|
|
51
|
+
function extractUrlFileName(urlStr) {
|
|
52
|
+
if (urlStr.startsWith('http')) {
|
|
53
|
+
const urlPath = new URL(urlStr).pathname;
|
|
54
|
+
return /[^/]+$/.exec(urlPath)?.[0] ?? urlPath;
|
|
55
|
+
}
|
|
56
|
+
const fname = /[^/\\]+$/.exec(urlStr)?.[0];
|
|
57
|
+
if (fname) {
|
|
58
|
+
return fname;
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Could not extract file name from "${urlStr}"`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {string} urlStr @returns {"zip" | "tar"} */
|
|
64
|
+
function getArchiveType(urlStr) {
|
|
65
|
+
const fname = extractUrlFileName(urlStr);
|
|
66
|
+
if (fname.endsWith(".zip")) return "zip";
|
|
67
|
+
if (/\.tar(\.[^.]+)?$|\.tgz$/.test(fname)) return "tar";
|
|
68
|
+
throw new Error(`Unsupported archive type for ${fname}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Executes a callback when the variable goes out of scope.
|
|
73
|
+
* @param {() => void} callback
|
|
74
|
+
* @returns {Disposable}
|
|
75
|
+
*/
|
|
76
|
+
export const defer = (callback) => ({
|
|
77
|
+
[Symbol.dispose]: callback
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tries to download from an array of URLs.
|
|
82
|
+
* @param {string[]} urls
|
|
83
|
+
* @param {string} dest
|
|
84
|
+
*/
|
|
85
|
+
async function downloadWithRetry(urls, dest) {
|
|
86
|
+
const urlArray = Array.isArray(urls) ? urls : [urls];
|
|
87
|
+
let lastErr;
|
|
88
|
+
|
|
89
|
+
for (const url of urlArray) {
|
|
90
|
+
try {
|
|
91
|
+
console.log(`Downloading: ${url}`);
|
|
92
|
+
if (/^https?:/.test(url)) {
|
|
93
|
+
return await download(url, dest);
|
|
94
|
+
} else if (existsSync(url)) {
|
|
95
|
+
return await copyFile(url, dest);
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Path not found: ${url}`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const msg = err instanceof Error ? err.message : err?.toString();
|
|
100
|
+
console.warn(`Failed to download from ${url}: ${msg}`);
|
|
101
|
+
lastErr = err;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw lastErr;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string | URL} url
|
|
109
|
+
* @param {import("node:fs").PathLike} dest
|
|
110
|
+
*/
|
|
111
|
+
async function download(url, dest) {
|
|
112
|
+
const response = await fetch(url);
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(`HTTP ${response.status}`);
|
|
115
|
+
}
|
|
116
|
+
if (!response.body) {
|
|
117
|
+
throw new Error("Invalid response body");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fileStream = createWriteStream(dest);
|
|
121
|
+
const reader = response.body.getReader();
|
|
122
|
+
while (true) {
|
|
123
|
+
const { done, value } = await reader.read();
|
|
124
|
+
if (done) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
fileStream.write(value);
|
|
128
|
+
}
|
|
129
|
+
fileStream.end();
|
|
130
|
+
return finished(fileStream);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Atomic extraction into the versioned folder.
|
|
135
|
+
* @param {string} archivePath
|
|
136
|
+
* @param {string} binPath - Path to the binary within the archive
|
|
137
|
+
* @returns {Promise<{dir: string, bin: string}>} - Path to the extracted release directory and binary
|
|
138
|
+
*/
|
|
139
|
+
async function extractRelease(archivePath, binPath) {
|
|
140
|
+
const archiveType = getArchiveType(archivePath);
|
|
141
|
+
console.log(`Extracting ${archiveType}...`);
|
|
142
|
+
|
|
143
|
+
const stagingDir = join(BASE_BIN_DIR, `.staging-${randomBytes(4).toString("hex")}`);
|
|
144
|
+
await mkdir(stagingDir, { recursive: true });
|
|
145
|
+
|
|
146
|
+
if (archiveType === "tar") {
|
|
147
|
+
try {
|
|
148
|
+
execSync(`tar -xf "${archivePath}" -C "${stagingDir}"`);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (process.platform === "win32") {
|
|
152
|
+
throw new Error(`Unsupported archive type for platform ${process.platform}`);
|
|
153
|
+
}
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
if (process.platform === "win32") {
|
|
158
|
+
execSync(`powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${stagingDir}'"`);
|
|
159
|
+
} else {
|
|
160
|
+
execSync(`unzip "${archivePath}" -d "${stagingDir}"`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Add Windows .exe suffix if not accounted for in manifest
|
|
165
|
+
let sourcePath = join(stagingDir, binPath);
|
|
166
|
+
if (process.platform === "win32" && !sourcePath.endsWith(".exe")) {
|
|
167
|
+
if (!existsSync(sourcePath) && existsSync(sourcePath + ".exe")) {
|
|
168
|
+
sourcePath += ".exe";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Atomically move staging to versioned dir
|
|
173
|
+
if (existsSync(VERSIONED_DIR)) {
|
|
174
|
+
await rm(VERSIONED_DIR, { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
await rename(stagingDir, VERSIONED_DIR);
|
|
177
|
+
|
|
178
|
+
let bin = join(VERSIONED_DIR, binPath);
|
|
179
|
+
if (process.platform === "win32" && !sourcePath.endsWith(".exe")) {
|
|
180
|
+
if (!existsSync(bin) && existsSync(bin + ".exe")) {
|
|
181
|
+
bin += ".exe";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
dir: VERSIONED_DIR,
|
|
187
|
+
bin,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Creates a symlink or copies the file, dependent on platform.
|
|
193
|
+
* @param {string} target
|
|
194
|
+
* @param {string} linkPath
|
|
195
|
+
*/
|
|
196
|
+
async function linkBinary(target, linkPath) {
|
|
197
|
+
try {
|
|
198
|
+
// Don't check if it exists first because we need to also remove broken symlinks
|
|
199
|
+
await unlink(linkPath);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.error(`Failed to remove existing binary: ${err}`);
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const rel = relative(dirname(linkPath), target);
|
|
206
|
+
if (process.platform === "win32") {
|
|
207
|
+
const runner = `@ECHO OFF\nSETLOCAL\n"%~dp0${rel}" %*`;
|
|
208
|
+
await writeFile(linkPath, runner, "utf8");
|
|
209
|
+
} else {
|
|
210
|
+
// *nix: Always create relative symlink
|
|
211
|
+
await symlink(rel, linkPath);
|
|
212
|
+
await chmod(target, 0o755);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function removeOldVersions() {
|
|
217
|
+
const items = await readdir(BASE_BIN_DIR);
|
|
218
|
+
const currentName = basename(VERSIONED_DIR);
|
|
219
|
+
for (const item of items) {
|
|
220
|
+
if (item.startsWith(`v`) && item !== currentName) {
|
|
221
|
+
await rm(join(BASE_BIN_DIR, item), { recursive: true, force: true }).catch(() => {});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @overload
|
|
228
|
+
* @param {string | URL} pathOrUrl
|
|
229
|
+
* @param {"text"} result
|
|
230
|
+
* @returns {Promise<string>}
|
|
231
|
+
*/
|
|
232
|
+
/**
|
|
233
|
+
* @overload
|
|
234
|
+
* @param {string | URL} pathOrUrl
|
|
235
|
+
* @param {"json"} result
|
|
236
|
+
* @returns {Promise<any>}
|
|
237
|
+
*/
|
|
238
|
+
/**
|
|
239
|
+
* @param {string | URL} pathOrUrl
|
|
240
|
+
* @param {"text" | "json"} result
|
|
241
|
+
* @returns {Promise<string | any>}
|
|
242
|
+
*/
|
|
243
|
+
async function resolve(pathOrUrl, result) {
|
|
244
|
+
if (/^https?:/.test(pathOrUrl.toString())) {
|
|
245
|
+
const response = await fetch(pathOrUrl);
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
|
|
248
|
+
}
|
|
249
|
+
return result === "text" ? await response.text() : await response.json();
|
|
250
|
+
} else if (existsSync(pathOrUrl)) {
|
|
251
|
+
const text = await readFile(pathOrUrl, { encoding: "utf8" });
|
|
252
|
+
return result === "text" ? text : JSON.parse(text);
|
|
253
|
+
} else {
|
|
254
|
+
throw new Error(`Unable to resolve path/url ${pathOrUrl}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function main() {
|
|
259
|
+
let downgradeError = false;
|
|
260
|
+
try {
|
|
261
|
+
await mkdir(BASE_BIN_DIR, { recursive: true });
|
|
262
|
+
|
|
263
|
+
console.log("Fetching manifest...");
|
|
264
|
+
|
|
265
|
+
/** @type {Manifest} */
|
|
266
|
+
const manifest = await (async () => {
|
|
267
|
+
for (const url of MANIFEST_URLS) {
|
|
268
|
+
try {
|
|
269
|
+
const manifest = await resolve(url, "json");
|
|
270
|
+
return manifest;
|
|
271
|
+
} catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
throw new Error("Unable to load application manifest");
|
|
276
|
+
})();
|
|
277
|
+
const tuple = getPlatformTuple();
|
|
278
|
+
const entry = manifest.precompiled[tuple];
|
|
279
|
+
|
|
280
|
+
if (!entry) {
|
|
281
|
+
downgradeError = true;
|
|
282
|
+
throw new Error(`Precompiled binaries for ${tuple} not found`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Normalize manifest entry:
|
|
286
|
+
// We allow tuples to point to either a "url like" (url or array of urls)
|
|
287
|
+
// or an object containing a bin_path override + a "url like".
|
|
288
|
+
const urls = (typeof entry === "string" ? [entry] : Array.isArray(entry) ? entry : (Array.isArray(entry.url) ? entry.url : [entry.url]))
|
|
289
|
+
.filter(url => !!url);
|
|
290
|
+
const binPathInside = (typeof entry === "object" && !Array.isArray(entry) && entry.bin_path) || manifest.bin_path;
|
|
291
|
+
|
|
292
|
+
if (!urls[0]) {
|
|
293
|
+
throw new Error(`Missing a url for target ${tuple}`);
|
|
294
|
+
}
|
|
295
|
+
const archivePath = join(tmpdir(), extractUrlFileName(urls[0]));
|
|
296
|
+
using _ = defer(async () => {
|
|
297
|
+
if (existsSync(archivePath)) {
|
|
298
|
+
await rm(archivePath);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
await downloadWithRetry(urls, archivePath);
|
|
302
|
+
|
|
303
|
+
const { bin} = await extractRelease(archivePath, binPathInside);
|
|
304
|
+
|
|
305
|
+
await linkBinary(bin, ENTRY_POINT);
|
|
306
|
+
await removeOldVersions();
|
|
307
|
+
|
|
308
|
+
console.log(`Success: Installed precompiled native ${tuple} ${BIN_NAME}`);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
const msg = err instanceof Error ? err.message : err?.toString();
|
|
311
|
+
if (downgradeError) {
|
|
312
|
+
console.info(msg);
|
|
313
|
+
} else {
|
|
314
|
+
console.warn(`Binary installation failed: ${msg}.`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.info(`Falling back to wasm ${BIN_NAME}...`);
|
|
318
|
+
|
|
319
|
+
if (existsSync(ENTRY_POINT)) {
|
|
320
|
+
await unlink(ENTRY_POINT);
|
|
321
|
+
}
|
|
322
|
+
if (process.platform !== "win32") {
|
|
323
|
+
await linkBinary(FALLBACK_JS, ENTRY_POINT);
|
|
324
|
+
} else {
|
|
325
|
+
const rel = relative(dirname(ENTRY_POINT), FALLBACK_JS);
|
|
326
|
+
console.debug({
|
|
327
|
+
rel,
|
|
328
|
+
ENTRY_POINT,
|
|
329
|
+
FALLBACK_JS,
|
|
330
|
+
});
|
|
331
|
+
const runner = `@ECHO OFF\nSETLOCAL\nnode --experimental-wasi-unstable-preview1 "%~dp0${rel}" %*`;
|
|
332
|
+
await writeFile(ENTRY_POINT, runner, "utf8");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neosmart/ssclient",
|
|
3
|
+
"description": "SecureStore cli client, cross-platform and sandboxed",
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"SecureStore",
|
|
7
|
+
"encryption",
|
|
8
|
+
"secrets"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://neosmart.net/SecureStore/",
|
|
11
|
+
"bugs": "https://github.com/neosmart/securestore-rs",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/neosmart/securestore-rs.git"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Mahmoud Al-Qudsi",
|
|
19
|
+
"email": "mqudsi@neosmart.net",
|
|
20
|
+
"url": "https://github.com/mqudsi"
|
|
21
|
+
},
|
|
22
|
+
"funding": "https://mqudsi.com/donate/",
|
|
23
|
+
"bin": {
|
|
24
|
+
"ssclient": "bin/ssclient.cmd"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"./README.md",
|
|
28
|
+
"./bin/ssclient.cmd",
|
|
29
|
+
"./bin/ssclient.wasm",
|
|
30
|
+
"./build.js",
|
|
31
|
+
"./install.js",
|
|
32
|
+
"./ssclient.js",
|
|
33
|
+
"./tsconfig.js"
|
|
34
|
+
],
|
|
35
|
+
"type": "module",
|
|
36
|
+
"scripts": {
|
|
37
|
+
"prepublishOnly": "npm run lint && npm run build",
|
|
38
|
+
"prepare": "echo 'This placeholder file will be overridden by our install scripts' > bin/ssclient.cmd",
|
|
39
|
+
"install": "node ./install.js",
|
|
40
|
+
"ssclient": "node ./ssclient.js",
|
|
41
|
+
"build": "node ./build.js",
|
|
42
|
+
"lint": "tsc"
|
|
43
|
+
},
|
|
44
|
+
"devDependecies": [
|
|
45
|
+
"@types/node"
|
|
46
|
+
],
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"typescript": "^6.0.2"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/ssclient.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join, dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const WASM_FILE = join(__dirname, "bin/ssclient.wasm");
|
|
12
|
+
|
|
13
|
+
// Silence node warnings about WASI being in preview, as we test functionality ourselves
|
|
14
|
+
if (process && process.emitWarning) {
|
|
15
|
+
const emitWarning = process.emitWarning;
|
|
16
|
+
process.emitWarning = (warning, type) => {
|
|
17
|
+
if (type === "ExperimentalWarning") {
|
|
18
|
+
if (warning.toString().includes("WASI")) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// @ts-ignore
|
|
23
|
+
return emitWarning(arguments);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Import WASI dynamically to ensure our ExperimentalWarning intercept takes place first
|
|
28
|
+
// Also figure out if we need to relaunch with a flag for compatibility with older node versions.
|
|
29
|
+
|
|
30
|
+
/** @type {typeof import("node:wasi").WASI} */
|
|
31
|
+
let WASI;
|
|
32
|
+
try {
|
|
33
|
+
const wasiModule = await import("node:wasi");
|
|
34
|
+
WASI = wasiModule.WASI;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// If we can't import it, we need the --experimental-wasi-unstable-preview1 flag.
|
|
37
|
+
const scriptPath = fileURLToPath(import.meta.url);
|
|
38
|
+
|
|
39
|
+
if (process.argv.find(arg => arg === "--experimental-wasi-unstable-preview1")) {
|
|
40
|
+
// Already tried launching with this flag and it didn't work
|
|
41
|
+
console.error(`Unable to load WASI module: ${e}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Re-spawn the current process with the flag enabled
|
|
46
|
+
const child = spawn(
|
|
47
|
+
process.execPath,
|
|
48
|
+
["--experimental-wasi-unstable-preview1", scriptPath, ...process.argv.slice(2)],
|
|
49
|
+
{ stdio: "inherit" }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
53
|
+
// Block indefinitely until child has exited
|
|
54
|
+
await new Promise(() => {});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} payload - Path to the WASM binary
|
|
59
|
+
*/
|
|
60
|
+
async function runWasm(payload) {
|
|
61
|
+
// Extract node-specific values
|
|
62
|
+
const args = process.argv.slice(2);
|
|
63
|
+
const cwd = process.cwd();
|
|
64
|
+
const env = process.env;
|
|
65
|
+
|
|
66
|
+
const wasi = new WASI({
|
|
67
|
+
version: "preview1",
|
|
68
|
+
args: ["ssclient", ...args],
|
|
69
|
+
env: env,
|
|
70
|
+
// Map paths we need into the sandbox
|
|
71
|
+
preopens: {
|
|
72
|
+
[cwd]: cwd,
|
|
73
|
+
".": ".",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// wasi.getImportObject() isn't yet supported by bun
|
|
78
|
+
// Reported upstream at https://github.com/oven-sh/bun/issues/28534
|
|
79
|
+
/** @type {any} */
|
|
80
|
+
const wasiImportObject = wasi.getImportObject ? wasi.getImportObject()
|
|
81
|
+
: { wasi_snapshot_preview1: wasi.wasiImport };
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const wasmPath = resolve(payload);
|
|
85
|
+
const wasmBuffer = await readFile(wasmPath);
|
|
86
|
+
const wasmModule = await WebAssembly.compile(wasmBuffer);
|
|
87
|
+
const instance = await WebAssembly.instantiate(
|
|
88
|
+
wasmModule,
|
|
89
|
+
wasiImportObject,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
wasi.start(instance);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const msg = err instanceof Error ? err.message : err?.toString();
|
|
95
|
+
console.error(`Error executing ${payload}: `, msg);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await runWasm(WASM_FILE);
|