gspace 0.0.0-stage → 0.1.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/THIRD_PARTY_NOTICES.txt +61 -0
- package/bin/gspace.js +9 -0
- package/lib/bootstrap.js +409 -0
- package/lib/bundle.js +151 -0
- package/lib/https.js +38 -0
- package/package.json +30 -4
- package/release.json +1 -0
- package/README.md +0 -3
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Third-party notices for the Gspace native backend
|
|
2
|
+
|
|
3
|
+
These notices cover embedded CLI11 and nlohmann/json code. Python dependency
|
|
4
|
+
wheels carry their own license metadata. This file does not assign a license
|
|
5
|
+
to Gspace itself.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CLI11 2.5.0
|
|
9
|
+
===========
|
|
10
|
+
|
|
11
|
+
CLI11 2.2 Copyright (c) 2017-2025 University of Cincinnati, developed by Henry
|
|
12
|
+
Schreiner under NSF AWARD 1414736. All rights reserved.
|
|
13
|
+
|
|
14
|
+
Redistribution and use in source and binary forms of CLI11, with or without
|
|
15
|
+
modification, are permitted provided that the following conditions are met:
|
|
16
|
+
|
|
17
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
18
|
+
list of conditions and the following disclaimer.
|
|
19
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
20
|
+
this list of conditions and the following disclaimer in the documentation
|
|
21
|
+
and/or other materials provided with the distribution.
|
|
22
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
23
|
+
may be used to endorse or promote products derived from this software without
|
|
24
|
+
specific prior written permission.
|
|
25
|
+
|
|
26
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
27
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
28
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
29
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
30
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
31
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
32
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
33
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
34
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
35
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
nlohmann/json 3.12.0
|
|
39
|
+
====================
|
|
40
|
+
|
|
41
|
+
MIT License
|
|
42
|
+
|
|
43
|
+
Copyright (c) 2013-2025 Niels Lohmann
|
|
44
|
+
|
|
45
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
46
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
47
|
+
in the Software without restriction, including without limitation the rights
|
|
48
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
49
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
50
|
+
furnished to do so, subject to the following conditions:
|
|
51
|
+
|
|
52
|
+
The above copyright notice and this permission notice shall be included in all
|
|
53
|
+
copies or substantial portions of the Software.
|
|
54
|
+
|
|
55
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
56
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
57
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
58
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
59
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
60
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
61
|
+
SOFTWARE.
|
package/bin/gspace.js
ADDED
package/lib/bootstrap.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { constants, promises as fs, createReadStream } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
5
|
+
import { createHash, randomBytes } from 'crypto';
|
|
6
|
+
import { spawn, execFile } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import { Readable } from 'stream';
|
|
9
|
+
import { requestHTTPS, cancelBody } from './https.js';
|
|
10
|
+
import { extractBundle, validateFiles, verifyBundle } from './bundle.js';
|
|
11
|
+
|
|
12
|
+
const VERSION = '0.1.2';
|
|
13
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
14
|
+
const MAX_ARTIFACT = 256 * 1024 * 1024;
|
|
15
|
+
const nonce = () => randomBytes(12).toString('hex');
|
|
16
|
+
const uid = () => process.getuid();
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
export async function availableSpace(directory, statfs = fs.statfs) {
|
|
20
|
+
if (typeof statfs === 'function') {
|
|
21
|
+
const space = await statfs(directory);
|
|
22
|
+
return space.bavail * space.bsize;
|
|
23
|
+
}
|
|
24
|
+
// Ubuntu 22.04's Node 12 predates fs.statfs. GNU df is part of the base
|
|
25
|
+
// workstation; use an absolute path and argument vector, never a shell.
|
|
26
|
+
const result = await execFileAsync('/usr/bin/df', ['--output=avail', '--block-size=1', '--', directory], {
|
|
27
|
+
env: { ...process.env, LC_ALL: 'C' }, timeout: 10000, maxBuffer: 65536,
|
|
28
|
+
});
|
|
29
|
+
const match = /^\s*Avail\s*\n\s*(\d+)\s*$/.exec(result.stdout);
|
|
30
|
+
const bytes = match && Number(match[1]);
|
|
31
|
+
if (!match || !Number.isSafeInteger(bytes)) throw new Error('Cannot determine available goinfre space.');
|
|
32
|
+
return bytes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function exists(file) {
|
|
36
|
+
try { await fs.lstat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function safeDirectory(directory, create = false) {
|
|
40
|
+
if (!path.isAbsolute(directory)) throw new Error('Expected an absolute runtime directory.');
|
|
41
|
+
let current = path.parse(directory).root;
|
|
42
|
+
for (const part of directory.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
43
|
+
current = path.join(current, part);
|
|
44
|
+
if (create) {
|
|
45
|
+
try { await fs.mkdir(current, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
46
|
+
}
|
|
47
|
+
const info = await fs.lstat(current);
|
|
48
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe directory: ${current}`);
|
|
49
|
+
}
|
|
50
|
+
return directory;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function privateDirectory(directory) {
|
|
54
|
+
await safeDirectory(directory, true);
|
|
55
|
+
const info = await fs.lstat(directory);
|
|
56
|
+
if (info.uid !== uid() || (info.mode & 0o022)) throw new Error(`Runtime directory must be owned by you and not writable by others: ${directory}`);
|
|
57
|
+
return directory;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function environment(env = process.env) {
|
|
61
|
+
if (process.platform !== 'linux' || process.arch !== 'x64') throw new Error('Gspace currently supports Linux x86_64.');
|
|
62
|
+
if (!env.HOME || !path.isAbsolute(env.HOME)) throw new Error('HOME must be an absolute existing directory.');
|
|
63
|
+
if (!env.USER || !/^[A-Za-z0-9_.-]+$/.test(env.USER) || ['.', '..'].includes(env.USER)) throw new Error('USER is missing or unsafe.');
|
|
64
|
+
const home = await fs.realpath(env.HOME);
|
|
65
|
+
await safeDirectory(home);
|
|
66
|
+
for (const candidate of [env.GOINFRE, `/goinfre/${env.USER}`, path.join(home, 'goinfre')]) {
|
|
67
|
+
if (!candidate || !path.isAbsolute(candidate)) continue;
|
|
68
|
+
try {
|
|
69
|
+
await fs.access(candidate, constants.W_OK | constants.X_OK);
|
|
70
|
+
const goinfre = await fs.realpath(candidate);
|
|
71
|
+
if (!(await fs.stat(goinfre)).isDirectory()) continue;
|
|
72
|
+
if (goinfre === home || goinfre === '/') throw new Error('Goinfre must be separate from HOME and /.');
|
|
73
|
+
await safeDirectory(goinfre);
|
|
74
|
+
const info = await fs.stat(goinfre);
|
|
75
|
+
if (info.uid !== uid() || (info.mode & 0o022)) throw new Error('Goinfre must be owned by you and not writable by others.');
|
|
76
|
+
return { home, goinfre, root: path.join(goinfre, '.gspace'), env: { ...env, HOME: home, GOINFRE: goinfre } };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!['ENOENT', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw new Error('No writable goinfre found. Set GOINFRE or prepare /goinfre/$USER or ~/goinfre.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readJSON(file, fallback = null) {
|
|
85
|
+
if (!await exists(file)) return fallback;
|
|
86
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
87
|
+
try {
|
|
88
|
+
const info = await handle.stat();
|
|
89
|
+
if (!info.isFile() || info.uid !== uid() || info.size > 1024 * 1024 || info.nlink !== 1) throw new Error(`Unsafe metadata: ${file}`);
|
|
90
|
+
return JSON.parse(await handle.readFile('utf8'));
|
|
91
|
+
} finally { await handle.close(); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function atomicJSON(file, value) {
|
|
95
|
+
await privateDirectory(path.dirname(file));
|
|
96
|
+
if (await exists(file)) {
|
|
97
|
+
const info = await fs.lstat(file);
|
|
98
|
+
if (!info.isFile() || info.uid !== uid() || info.nlink !== 1) throw new Error(`Unsafe metadata destination: ${file}`);
|
|
99
|
+
}
|
|
100
|
+
const temporary = `${file}.tmp-${nonce()}`;
|
|
101
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
102
|
+
try {
|
|
103
|
+
await handle.writeFile(JSON.stringify(value, null, 2) + '\n');
|
|
104
|
+
await handle.sync();
|
|
105
|
+
await handle.close();
|
|
106
|
+
await fs.rename(temporary, file);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
await handle.close().catch(() => {});
|
|
109
|
+
await fs.unlink(temporary).catch(() => {});
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function asURL(location) {
|
|
115
|
+
if (/^https?:|^file:/.test(location)) return new URL(location);
|
|
116
|
+
return pathToFileURL(path.resolve(location));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function fetchHTTPS(url, timeout = 60000, request = requestHTTPS, headers = {}) {
|
|
120
|
+
const deadline = Date.now() + timeout;
|
|
121
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
122
|
+
url = new URL(url);
|
|
123
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Release downloads require HTTPS without embedded credentials.');
|
|
124
|
+
const response = await request(url, { timeout: Math.max(1, deadline - Date.now()), redirect: 'manual', headers });
|
|
125
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
126
|
+
const location = response.headers.get('location');
|
|
127
|
+
await cancelBody(response);
|
|
128
|
+
if (!location) throw new Error('Release redirect has no destination.');
|
|
129
|
+
url = new URL(location, url);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
await cancelBody(response);
|
|
134
|
+
throw new Error(`Release download failed: HTTP ${response.status}`);
|
|
135
|
+
}
|
|
136
|
+
return response;
|
|
137
|
+
}
|
|
138
|
+
throw new Error('Too many release redirects.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function fetchBytes(url, limit) {
|
|
142
|
+
if (url.protocol === 'file:') {
|
|
143
|
+
const info = await fs.stat(fileURLToPath(url));
|
|
144
|
+
if (!info.isFile() || info.size > limit) throw new Error('Release file is oversized or not a file.');
|
|
145
|
+
return fs.readFile(fileURLToPath(url));
|
|
146
|
+
}
|
|
147
|
+
if (url.protocol !== 'https:') throw new Error('Remote releases require HTTPS. Use a local file URL for offline installs.');
|
|
148
|
+
const response = await fetchHTTPS(url);
|
|
149
|
+
let size = 0;
|
|
150
|
+
const chunks = [];
|
|
151
|
+
for await (const chunk of response.body) {
|
|
152
|
+
size += chunk.length;
|
|
153
|
+
if (size > limit) throw new Error('Release metadata exceeds its size limit.');
|
|
154
|
+
chunks.push(chunk);
|
|
155
|
+
}
|
|
156
|
+
return Buffer.concat(chunks);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function artifact(value, source) {
|
|
160
|
+
if (!value || !/^[a-f0-9]{64}$/.test(value.sha256) || !Number.isSafeInteger(value.size) || value.size <= 0 || value.size > MAX_ARTIFACT || typeof value.url !== 'string') throw new Error('Invalid release artifact metadata.');
|
|
161
|
+
const url = new URL(value.url, source);
|
|
162
|
+
if (source.protocol === 'https:' && url.protocol !== 'https:') throw new Error('Remote manifests cannot reference local or insecure artifacts.');
|
|
163
|
+
if (!['https:', 'file:'].includes(url.protocol)) throw new Error('Unsupported artifact URL.');
|
|
164
|
+
return { ...value, url: url.href };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function validateManifest(value, source) {
|
|
168
|
+
if (!value || value.schema_version !== 2 || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(value.version)) throw new Error('Unsupported release manifest.');
|
|
169
|
+
const platform = value.platforms && value.platforms['linux-x86_64'];
|
|
170
|
+
if (!platform || !/^2\.\d+$/.test(platform.glibc_min)) throw new Error('Release lacks a supported Linux x86_64 bundle.');
|
|
171
|
+
const unpacked = validateFiles(platform.files);
|
|
172
|
+
if (unpacked !== platform.unpacked_size) throw new Error('Invalid unpacked size.');
|
|
173
|
+
return { schema_version: 2, version: value.version, bundle: artifact(platform.bundle, source), files: platform.files, unpacked_size: unpacked, glibc_min: platform.glibc_min };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function sha256(file) {
|
|
177
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
178
|
+
try {
|
|
179
|
+
const info = await handle.stat();
|
|
180
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > MAX_ARTIFACT) throw new Error('Unsafe runtime artifact.');
|
|
181
|
+
const hash = createHash('sha256');
|
|
182
|
+
for await (const chunk of createReadStream(null, { fd: handle.fd, autoClose: false })) hash.update(chunk);
|
|
183
|
+
return hash.digest('hex');
|
|
184
|
+
} finally { await handle.close(); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const RANGE_SIZE = 1024 * 1024;
|
|
188
|
+
|
|
189
|
+
async function downloadRanges(value, handle, first, request) {
|
|
190
|
+
async function copy(response, start, end) {
|
|
191
|
+
if (response.status !== 206 || response.headers.get('content-range') !== `bytes ${start}-${end}/${value.size}`) {
|
|
192
|
+
await cancelBody(response);
|
|
193
|
+
throw new Error('Invalid artifact byte range.');
|
|
194
|
+
}
|
|
195
|
+
let position = start;
|
|
196
|
+
for await (const chunk of response.body) {
|
|
197
|
+
if (position + chunk.length > end + 1) throw new Error('Artifact byte range exceeds its declared size.');
|
|
198
|
+
let offset = 0;
|
|
199
|
+
while (offset < chunk.length) {
|
|
200
|
+
const { bytesWritten } = await handle.write(chunk, offset, chunk.length - offset, position);
|
|
201
|
+
if (!bytesWritten) throw new Error('Unable to write artifact byte range.');
|
|
202
|
+
offset += bytesWritten;
|
|
203
|
+
position += bytesWritten;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (position !== end + 1) throw new Error('Truncated artifact byte range.');
|
|
207
|
+
}
|
|
208
|
+
await copy(first, 0, 0);
|
|
209
|
+
let next = 1;
|
|
210
|
+
const workers = Array.from({ length: Math.min(8, Math.ceil((value.size - 1) / RANGE_SIZE)) }, async () => {
|
|
211
|
+
while (next < value.size) {
|
|
212
|
+
const start = next;
|
|
213
|
+
next += RANGE_SIZE;
|
|
214
|
+
const end = Math.min(value.size, start + RANGE_SIZE) - 1;
|
|
215
|
+
await copy(await request(start, end), start, end);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
// Settle all writers before closing/removing an unsuccessful download.
|
|
219
|
+
const results = await Promise.allSettled(workers);
|
|
220
|
+
const failure = results.find(result => result.status === 'rejected');
|
|
221
|
+
if (failure) throw failure.reason;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function downloadVerified(value, destination, transport = requestHTTPS) {
|
|
225
|
+
if (!Number.isSafeInteger(value.size) || value.size <= 0 || value.size > MAX_ARTIFACT || !/^[a-f0-9]{64}$/.test(value.sha256)) throw new Error('Invalid release artifact metadata.');
|
|
226
|
+
const url = new URL(value.url);
|
|
227
|
+
const temporary = `${destination}.download-${nonce()}`;
|
|
228
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
229
|
+
try {
|
|
230
|
+
let stream;
|
|
231
|
+
let ranged = false;
|
|
232
|
+
if (url.protocol === 'file:') stream = createReadStream(fileURLToPath(url));
|
|
233
|
+
else if (url.protocol === 'https:') {
|
|
234
|
+
const request = (start, end) => fetchHTTPS(url, 600000, transport, { Range: `bytes=${start}-${end}` });
|
|
235
|
+
const response = value.size > RANGE_SIZE ? await request(0, 0) : await fetchHTTPS(url, 600000, transport);
|
|
236
|
+
if (response.status === 206 && value.size > RANGE_SIZE) {
|
|
237
|
+
await downloadRanges(value, handle, response, request);
|
|
238
|
+
ranged = true;
|
|
239
|
+
} else {
|
|
240
|
+
// Servers without range support can return the entire file as HTTP 200.
|
|
241
|
+
if (response.status !== 200) { await cancelBody(response); throw new Error('Unexpected artifact response.'); }
|
|
242
|
+
stream = Readable.from(response.body);
|
|
243
|
+
}
|
|
244
|
+
} else throw new Error('Artifact requires HTTPS or a local file URL.');
|
|
245
|
+
const hash = createHash('sha256');
|
|
246
|
+
let total = 0;
|
|
247
|
+
if (!ranged) {
|
|
248
|
+
for await (const chunk of stream) {
|
|
249
|
+
total += chunk.length;
|
|
250
|
+
if (total > value.size || total > MAX_ARTIFACT) { stream.destroy(); throw new Error('Artifact exceeds its declared size.'); }
|
|
251
|
+
hash.update(chunk);
|
|
252
|
+
await handle.writeFile(chunk);
|
|
253
|
+
}
|
|
254
|
+
if (total !== value.size || hash.digest('hex') !== value.sha256) throw new Error('Artifact checksum or size mismatch; refusing execution.');
|
|
255
|
+
} else if ((await handle.stat()).size !== value.size || await sha256(temporary) !== value.sha256) {
|
|
256
|
+
throw new Error('Artifact checksum or size mismatch; refusing execution.');
|
|
257
|
+
}
|
|
258
|
+
await handle.sync();
|
|
259
|
+
await handle.close();
|
|
260
|
+
if (await exists(destination)) throw new Error('Refusing to overwrite an existing runtime artifact.');
|
|
261
|
+
await fs.link(temporary, destination);
|
|
262
|
+
await fs.unlink(temporary);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
await handle.close().catch(() => {});
|
|
265
|
+
await fs.unlink(temporary).catch(() => {});
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function run(executable, args, options = {}) {
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
const child = spawn(executable, args, { shell: false, stdio: options.stdio === undefined ? 'inherit' : options.stdio, env: options.env || process.env });
|
|
273
|
+
const forward = signal => { if (!child.killed) child.kill(signal); };
|
|
274
|
+
const onInt = () => forward('SIGINT'), onTerm = () => forward('SIGTERM');
|
|
275
|
+
process.on('SIGINT', onInt); process.on('SIGTERM', onTerm);
|
|
276
|
+
const remove = () => { process.off('SIGINT', onInt); process.off('SIGTERM', onTerm); };
|
|
277
|
+
child.once('error', error => { remove(); reject(new Error(`Cannot start ${executable}: ${error.message}`)); });
|
|
278
|
+
child.once('exit', (code, signal) => { remove(); resolve(code === null ? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1) : code); });
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function lock(root) {
|
|
283
|
+
const directory = path.join(root, 'runtime', 'install.lock');
|
|
284
|
+
await privateDirectory(path.dirname(directory));
|
|
285
|
+
try { await fs.mkdir(directory, { mode: 0o700 }); }
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (error.code !== 'EEXIST') throw error;
|
|
288
|
+
await safeDirectory(directory);
|
|
289
|
+
const info = await readJSON(path.join(directory, 'owner.json'));
|
|
290
|
+
if (info && info.hostname === os.hostname() && Number.isSafeInteger(info.pid) && info.pid > 1) {
|
|
291
|
+
let alive = true;
|
|
292
|
+
try { process.kill(info.pid, 0); } catch (failure) { if (failure.code === 'ESRCH') alive = false; }
|
|
293
|
+
if (!alive) {
|
|
294
|
+
await fs.rename(directory, `${directory}.stale-${nonce()}`);
|
|
295
|
+
return lock(root);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
throw new Error('Another installation is running, or an interrupted install lock needs manual review.');
|
|
299
|
+
}
|
|
300
|
+
await atomicJSON(path.join(directory, 'owner.json'), { pid: process.pid, hostname: os.hostname() });
|
|
301
|
+
return async () => { await fs.unlink(path.join(directory, 'owner.json')); await fs.rmdir(directory); };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function currentRuntime(root) {
|
|
305
|
+
const value = await readJSON(path.join(root, 'runtime', 'current.json'));
|
|
306
|
+
if (!value) return null;
|
|
307
|
+
// Reinstall experimental wheel-based runtimes using the standalone bundle.
|
|
308
|
+
if (value.schema_version === 1) return null;
|
|
309
|
+
if (value.schema_version !== 2 || typeof value.id !== 'string' || !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?-[a-f0-9]{16}(?:-[a-f0-9]{24})?$/.test(value.id) || !/^[a-f0-9]{64}$/.test(value.manifest_hash)) throw new Error('Corrupted runtime metadata.');
|
|
310
|
+
const directory = path.join(root, 'runtime', value.id);
|
|
311
|
+
await safeDirectory(directory);
|
|
312
|
+
await verifyBundle(directory, value.files);
|
|
313
|
+
const backend = path.join(directory, 'bin', 'gspace-core');
|
|
314
|
+
return { ...value, directory, backend, tui: path.join(directory, 'tui', 'gspace-tui') };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function prepareRuntime(context, manifest, manifestHash, logFd) {
|
|
318
|
+
const available = await availableSpace(context.goinfre);
|
|
319
|
+
const libc = process.report.getReport().header.glibcVersionRuntime;
|
|
320
|
+
const minimum = Number(manifest.glibc_min.split('.')[1]);
|
|
321
|
+
if (!libc || Number(libc.split('.')[0]) !== 2 || Number(libc.split('.')[1]) < minimum) throw new Error(`This release requires Linux glibc ${manifest.glibc_min} or newer.`);
|
|
322
|
+
if (available < manifest.bundle.size + manifest.unpacked_size + 64 * 1024 * 1024) throw new Error('Not enough goinfre space to download and unpack the runtime safely.');
|
|
323
|
+
const id = `${manifest.version}-${manifestHash.slice(0, 16)}`;
|
|
324
|
+
const directory = path.join(context.root, 'runtime', id);
|
|
325
|
+
// An incomplete version stays quarantined; a fresh installation gets a unique name.
|
|
326
|
+
const actualId = await exists(directory) ? `${id}-${nonce()}` : id;
|
|
327
|
+
const destination = path.join(context.root, 'runtime', actualId);
|
|
328
|
+
await privateDirectory(destination);
|
|
329
|
+
const archive = path.join(context.root, 'runtime', `${actualId}.tar.gz`);
|
|
330
|
+
console.error(`Downloading Gspace (${(manifest.bundle.size / 1048576).toFixed(1)} MiB)…`);
|
|
331
|
+
try {
|
|
332
|
+
await downloadVerified(manifest.bundle, archive);
|
|
333
|
+
console.error('Unpacking Gspace…');
|
|
334
|
+
await extractBundle(archive, destination, manifest.files);
|
|
335
|
+
} finally { await fs.unlink(archive).catch(() => {}); }
|
|
336
|
+
const backend = path.join(destination, 'bin', 'gspace-core');
|
|
337
|
+
const tui = path.join(destination, 'tui', 'gspace-tui');
|
|
338
|
+
const runtimeEnv = { ...context.env, PYTHONDONTWRITEBYTECODE: '1' };
|
|
339
|
+
delete runtimeEnv.PYTHONPATH;
|
|
340
|
+
delete runtimeEnv.PYTHONHOME;
|
|
341
|
+
await verifyBundle(destination, manifest.files);
|
|
342
|
+
if (await run(backend, ['--version'], { env: runtimeEnv, stdio: ['ignore', logFd, logFd] })) throw new Error('Downloaded backend did not start; see the bootstrap log.');
|
|
343
|
+
if (await run(tui, ['--version'], { env: runtimeEnv, stdio: ['ignore', logFd, logFd] })) throw new Error('Interface validation failed; see the bootstrap log.');
|
|
344
|
+
const result = { schema_version: 2, id: actualId, version: manifest.version, files: manifest.files, manifest_hash: manifestHash };
|
|
345
|
+
await atomicJSON(path.join(context.root, 'runtime', 'current.json'), result);
|
|
346
|
+
return currentRuntime(context.root);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function main(args, env = process.env) {
|
|
350
|
+
if (args.length === 1 && args[0] === '--version') { console.log(`gspace ${VERSION}`); return 0; }
|
|
351
|
+
if (env.GSPACE_CORE) {
|
|
352
|
+
if (args.length) return run(env.GSPACE_CORE, args, { env });
|
|
353
|
+
console.error('Launching Gspace…');
|
|
354
|
+
return run(env.GSPACE_TUI_PYTHON || env.GSPACE_PYTHON || 'python3', ['-m', 'gspace_tui', '--backend', env.GSPACE_CORE], { env });
|
|
355
|
+
}
|
|
356
|
+
const context = await environment(env);
|
|
357
|
+
await privateDirectory(context.root);
|
|
358
|
+
await privateDirectory(path.join(context.root, 'runtime'));
|
|
359
|
+
const logs = await privateDirectory(path.join(context.root, 'logs'));
|
|
360
|
+
const logPath = path.join(logs, 'bootstrap.log');
|
|
361
|
+
const log = await fs.open(logPath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
|
|
362
|
+
let releaseLock;
|
|
363
|
+
let runtime;
|
|
364
|
+
try {
|
|
365
|
+
const logInfo = await log.stat();
|
|
366
|
+
if (!logInfo.isFile() || logInfo.uid !== uid() || logInfo.nlink !== 1) throw new Error('Unsafe bootstrap log.');
|
|
367
|
+
releaseLock = await lock(context.root);
|
|
368
|
+
runtime = await currentRuntime(context.root);
|
|
369
|
+
const config = JSON.parse(await fs.readFile(new URL('../release.json', import.meta.url), 'utf8'));
|
|
370
|
+
const location = env.GSPACE_RELEASE_MANIFEST || config.manifest;
|
|
371
|
+
const forceUpdate = args[0] === '--update';
|
|
372
|
+
if (forceUpdate) args = args.slice(1);
|
|
373
|
+
const cacheFile = path.join(context.root, 'runtime', 'update.json');
|
|
374
|
+
let cache;
|
|
375
|
+
try { cache = await readJSON(cacheFile, {}); } catch { cache = {}; }
|
|
376
|
+
if (!runtime || forceUpdate || (location && Date.now() - (cache.checked_at || 0) >= DAY)) {
|
|
377
|
+
if (!location && !runtime) throw new Error('No release source configured. Set GSPACE_RELEASE_MANIFEST to a local manifest.json or HTTPS URL, or use GSPACE_CORE for a development build.');
|
|
378
|
+
if (location) {
|
|
379
|
+
try {
|
|
380
|
+
const source = asURL(location);
|
|
381
|
+
const raw = await fetchBytes(source, 1024 * 1024);
|
|
382
|
+
const manifest = validateManifest(JSON.parse(raw.toString('utf8')), source);
|
|
383
|
+
const hash = createHash('sha256').update(raw).digest('hex');
|
|
384
|
+
if (!runtime || runtime.manifest_hash !== hash) {
|
|
385
|
+
if (!runtime) console.error(`Gspace is not installed on this workstation.\nUsing ${context.goinfre}`);
|
|
386
|
+
runtime = await prepareRuntime(context, manifest, hash, log.fd);
|
|
387
|
+
}
|
|
388
|
+
await atomicJSON(cacheFile, { checked_at: Date.now(), location, version: manifest.version });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
await log.appendFile(`${new Date().toISOString()} ${error.stack || error.message}\n`);
|
|
391
|
+
if (!runtime) throw error;
|
|
392
|
+
console.error('Update unavailable; using the existing verified runtime.');
|
|
393
|
+
await atomicJSON(cacheFile, { checked_at: Date.now(), location, last_error: error.message });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw new Error(`${error.message}\nDetails: ${logPath}`);
|
|
399
|
+
} finally {
|
|
400
|
+
if (releaseLock) await releaseLock();
|
|
401
|
+
await log.close();
|
|
402
|
+
}
|
|
403
|
+
const runtimeEnv = { ...context.env, GSPACE_CORE: runtime.backend, PYTHONDONTWRITEBYTECODE: '1', PIP_NO_CACHE_DIR: '1' };
|
|
404
|
+
delete runtimeEnv.PYTHONPATH;
|
|
405
|
+
delete runtimeEnv.PYTHONHOME;
|
|
406
|
+
if (args.length) return run(runtime.backend, args, { env: runtimeEnv });
|
|
407
|
+
console.error('Launching Gspace…');
|
|
408
|
+
return run(runtime.tui, ['--backend', runtime.backend], { env: runtimeEnv });
|
|
409
|
+
}
|
package/lib/bundle.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { constants, createReadStream, promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { createGunzip } from 'zlib';
|
|
5
|
+
|
|
6
|
+
export const MAX_UNPACKED = 512 * 1024 * 1024;
|
|
7
|
+
const digest = /^[a-f0-9]{64}$/;
|
|
8
|
+
|
|
9
|
+
function safePath(value) {
|
|
10
|
+
return typeof value === 'string' && value.length <= 255 &&
|
|
11
|
+
/^[A-Za-z0-9_.+@/-]+$/.test(value) &&
|
|
12
|
+
value.split('/').every(part => part && part !== '.' && part !== '..');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateFiles(files) {
|
|
16
|
+
if (!Array.isArray(files) || !files.length || files.length > 4096) throw new Error('Invalid bundle file inventory.');
|
|
17
|
+
const names = new Set();
|
|
18
|
+
let total = 0;
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
if (!safePath(file.path) || names.has(file.path) || !digest.test(file.sha256) ||
|
|
21
|
+
!Number.isSafeInteger(file.size) || file.size < 0 || typeof file.executable !== 'boolean') {
|
|
22
|
+
throw new Error('Unsafe or duplicate bundle file metadata.');
|
|
23
|
+
}
|
|
24
|
+
names.add(file.path);
|
|
25
|
+
total += file.size;
|
|
26
|
+
if (total > MAX_UNPACKED) throw new Error('Bundle exceeds unpacked size limit.');
|
|
27
|
+
}
|
|
28
|
+
for (const name of names) {
|
|
29
|
+
const parts = name.split('/');
|
|
30
|
+
while (parts.pop() && parts.length) {
|
|
31
|
+
if (names.has(parts.join('/'))) throw new Error('Bundle file conflicts with a directory.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const name of ['bin/gspace-core', 'tui/gspace-tui']) {
|
|
35
|
+
if (!files.some(file => file.path === name && file.executable && file.size > 0)) throw new Error(`Bundle lacks ${name}.`);
|
|
36
|
+
}
|
|
37
|
+
return total;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function octal(bytes) {
|
|
41
|
+
const text = bytes.toString('ascii').replace(/\0.*$/s, '').trim();
|
|
42
|
+
if (!/^[0-7]+$/.test(text)) throw new Error('Invalid tar number.');
|
|
43
|
+
return Number.parseInt(text, 8);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Our release builder emits USTAR regular files only. Links, devices, extended
|
|
47
|
+
// headers, duplicates and unlisted entries are deliberately unsupported.
|
|
48
|
+
export async function extractBundle(archive, destination, files) {
|
|
49
|
+
const total = validateFiles(files);
|
|
50
|
+
const directoryInfo = await fs.lstat(destination);
|
|
51
|
+
if (!directoryInfo.isDirectory() || directoryInfo.uid !== process.getuid() || (directoryInfo.mode & 0o022) || (await fs.readdir(destination)).length) throw new Error('Bundle destination must be an empty private directory.');
|
|
52
|
+
const expected = new Map(files.map(file => [file.path, file]));
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const input = createReadStream(archive);
|
|
55
|
+
const unpacker = createGunzip();
|
|
56
|
+
input.on('error', error => unpacker.destroy(error));
|
|
57
|
+
input.pipe(unpacker);
|
|
58
|
+
const iterator = unpacker[Symbol.asyncIterator]();
|
|
59
|
+
let pending = Buffer.alloc(0), offset = 0, expanded = 0;
|
|
60
|
+
// Header and padding overhead are bounded separately from file payloads.
|
|
61
|
+
const limit = total + files.length * 1024 + 10240;
|
|
62
|
+
async function read(length, eof = false) {
|
|
63
|
+
const output = Buffer.alloc(length);
|
|
64
|
+
let written = 0;
|
|
65
|
+
while (written < length) {
|
|
66
|
+
if (offset === pending.length) {
|
|
67
|
+
const next = await iterator.next();
|
|
68
|
+
if (next.done) {
|
|
69
|
+
if (eof && written === 0) return null;
|
|
70
|
+
throw new Error('Truncated bundle archive.');
|
|
71
|
+
}
|
|
72
|
+
pending = next.value;
|
|
73
|
+
offset = 0;
|
|
74
|
+
expanded += pending.length;
|
|
75
|
+
if (expanded > limit) throw new Error('Bundle exceeds unpacked size limit.');
|
|
76
|
+
}
|
|
77
|
+
const count = Math.min(length - written, pending.length - offset);
|
|
78
|
+
pending.copy(output, written, offset, offset + count);
|
|
79
|
+
offset += count;
|
|
80
|
+
written += count;
|
|
81
|
+
}
|
|
82
|
+
return output;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
let finished = false, zeroBlocks = 0;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const header = await read(512, true);
|
|
88
|
+
if (!header) break;
|
|
89
|
+
if (header.every(byte => byte === 0)) { finished = true; zeroBlocks++; continue; }
|
|
90
|
+
if (finished) throw new Error('Unexpected data after tar terminator.');
|
|
91
|
+
let checksum = 0;
|
|
92
|
+
for (let index = 0; index < 512; index++) checksum += index >= 148 && index < 156 ? 32 : header[index];
|
|
93
|
+
if (checksum !== octal(header.subarray(148, 156)) || header.toString('ascii', 257, 263) !== 'ustar\0' || header.toString('ascii', 263, 265) !== '00') throw new Error('Invalid USTAR header.');
|
|
94
|
+
const field = (start, end) => header.subarray(start, end).toString('utf8').replace(/\0.*$/s, '');
|
|
95
|
+
const prefix = field(345, 500);
|
|
96
|
+
const name = (prefix ? prefix + '/' : '') + field(0, 100);
|
|
97
|
+
const file = expected.get(name);
|
|
98
|
+
if (!safePath(name) || !file || seen.has(name) || ![0, 48].includes(header[156]) || field(157, 257)) throw new Error('Unsafe, duplicate or unlisted archive entry.');
|
|
99
|
+
if (octal(header.subarray(124, 136)) !== file.size) throw new Error('Archive file size mismatch.');
|
|
100
|
+
seen.add(name);
|
|
101
|
+
const target = path.join(destination, name);
|
|
102
|
+
// Destination is a freshly created private directory. No archive entries
|
|
103
|
+
// can create symlinks, and exclusive opens never replace an existing file.
|
|
104
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
105
|
+
const output = await fs.open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, file.executable ? 0o700 : 0o600);
|
|
106
|
+
const hash = createHash('sha256');
|
|
107
|
+
try {
|
|
108
|
+
for (let remaining = file.size; remaining > 0;) {
|
|
109
|
+
const bytes = await read(Math.min(65536, remaining));
|
|
110
|
+
hash.update(bytes);
|
|
111
|
+
await output.writeFile(bytes);
|
|
112
|
+
remaining -= bytes.length;
|
|
113
|
+
}
|
|
114
|
+
if (hash.digest('hex') !== file.sha256) throw new Error('Unpacked file checksum mismatch.');
|
|
115
|
+
await output.sync();
|
|
116
|
+
} finally { await output.close(); }
|
|
117
|
+
const padding = await read((512 - file.size % 512) % 512);
|
|
118
|
+
if (padding.some(byte => byte !== 0)) throw new Error('Invalid tar padding.');
|
|
119
|
+
}
|
|
120
|
+
if (zeroBlocks < 2 || seen.size !== expected.size) throw new Error('Incomplete bundle archive.');
|
|
121
|
+
} finally {
|
|
122
|
+
input.destroy();
|
|
123
|
+
unpacker.destroy();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function verifyBundle(directory, files) {
|
|
128
|
+
validateFiles(files);
|
|
129
|
+
const expected = new Map(files.map(file => [file.path, file]));
|
|
130
|
+
let count = 0;
|
|
131
|
+
async function visit(relative) {
|
|
132
|
+
const location = path.join(directory, relative);
|
|
133
|
+
const info = await fs.lstat(location);
|
|
134
|
+
if (info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error('Unsafe runtime ownership or permissions.');
|
|
135
|
+
if (info.isDirectory()) {
|
|
136
|
+
for (const entry of await fs.readdir(location)) await visit(relative ? `${relative}/${entry}` : entry);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const file = expected.get(relative);
|
|
140
|
+
if (!file || !info.isFile() || info.nlink !== 1 || info.size !== file.size || Boolean(info.mode & 0o111) !== file.executable) throw new Error('Installed runtime checksum mismatch or unexpected file; refusing execution.');
|
|
141
|
+
const handle = await fs.open(location, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
142
|
+
try {
|
|
143
|
+
const hash = createHash('sha256');
|
|
144
|
+
for await (const bytes of createReadStream(null, { fd: handle.fd, autoClose: false })) hash.update(bytes);
|
|
145
|
+
if (hash.digest('hex') !== file.sha256) throw new Error('Installed runtime checksum mismatch; refusing execution.');
|
|
146
|
+
} finally { await handle.close(); }
|
|
147
|
+
count++;
|
|
148
|
+
}
|
|
149
|
+
await visit('');
|
|
150
|
+
if (count !== files.length) throw new Error('Installed runtime is incomplete; refusing execution.');
|
|
151
|
+
}
|
package/lib/https.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import https from 'https';
|
|
2
|
+
|
|
3
|
+
// Node 12 has HTTPS and async iterable streams, but no fetch/AbortSignal API.
|
|
4
|
+
// Keep TLS verification enabled and leave all redirect policy to the caller.
|
|
5
|
+
export function requestHTTPS(url, options = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
let incoming;
|
|
8
|
+
const request = https.get(url, { headers: options.headers || {} }, response => {
|
|
9
|
+
incoming = response;
|
|
10
|
+
response.once('end', clear);
|
|
11
|
+
response.once('close', clear);
|
|
12
|
+
response.once('error', clear);
|
|
13
|
+
// Match the small response interface used by the bootstrap and tests.
|
|
14
|
+
response.cancel = async () => { response.destroy(); clear(); };
|
|
15
|
+
resolve({
|
|
16
|
+
status: response.statusCode,
|
|
17
|
+
ok: response.statusCode >= 200 && response.statusCode < 300,
|
|
18
|
+
headers: { get: name => {
|
|
19
|
+
const value = response.headers[name.toLowerCase()];
|
|
20
|
+
return Array.isArray(value) ? value.join(', ') : value || null;
|
|
21
|
+
} },
|
|
22
|
+
body: response,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
const error = new Error('Release download timed out.');
|
|
27
|
+
request.destroy(error);
|
|
28
|
+
if (incoming) incoming.destroy(error);
|
|
29
|
+
}, options.timeout || 60000);
|
|
30
|
+
timer.unref();
|
|
31
|
+
function clear() { clearTimeout(timer); }
|
|
32
|
+
request.once('error', error => { clear(); reject(error); });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function cancelBody(response) {
|
|
37
|
+
if (response.body) await response.body.cancel();
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gspace",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Gspace launcher for 1337/42 Linux workstations",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+ssh://git@github.com/Seraph919/Gspace.git",
|
|
8
|
+
"directory": "npm"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"gspace": "bin/gspace.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"lib/",
|
|
17
|
+
"release.json",
|
|
18
|
+
"THIRD_PARTY_NOTICES.txt"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=12.22.9"
|
|
22
|
+
},
|
|
23
|
+
"os": [
|
|
24
|
+
"linux"
|
|
25
|
+
],
|
|
26
|
+
"cpu": [
|
|
27
|
+
"x64"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test test/*.test.js"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/release.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"manifest": "https://github.com/Seraph919/gspace-releases/releases/latest/download/manifest.json"}
|
package/README.md
DELETED