stackbite 0.0.0-stage → 0.5.11
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 +98 -2
- package/package.json +12 -6
- package/packaging/npm/bin/stackbite.js +13 -0
- package/packaging/npm/lib/archive.js +226 -0
- package/packaging/npm/lib/bootstrap.js +150 -0
- package/packaging/npm/lib/cache.js +10 -0
- package/packaging/npm/lib/common.js +51 -0
- package/packaging/npm/lib/download.js +52 -0
- package/packaging/npm/release.json +26 -0
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Stackbite through npm and npx
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Work Stack organizes the work. Stackbite gets it moving.
|
|
4
|
+
|
|
5
|
+
Node.js 20 or newer is required. The portable runtime is available for Windows x64
|
|
6
|
+
and Linux x64 with glibc; macOS, ARM and musl/Alpine builds are not supplied yet.
|
|
7
|
+
Python installation and compilation are unnecessary. Archive extraction requires
|
|
8
|
+
`tar` (included in current Windows and common Linux distributions).
|
|
9
|
+
|
|
10
|
+
## Install without an account
|
|
11
|
+
|
|
12
|
+
The public npm package is available as `stackbite`:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx --yes stackbite
|
|
16
|
+
npm install --global stackbite
|
|
17
|
+
stackbite
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Update a registry installation with `npm install --global stackbite@latest`.
|
|
21
|
+
|
|
22
|
+
## Direct GitHub package
|
|
23
|
+
|
|
24
|
+
Run the package attached to the verified GitHub release:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npx --yes https://github.com/Shinick-Han/stackbite/releases/download/v0.5.11/stackbite-0.5.11.tgz
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Arguments go after the package URL, for example `--version` or `--status`. For a
|
|
31
|
+
persistent command, install that same package globally:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm install --global https://github.com/Shinick-Han/stackbite/releases/download/v0.5.11/stackbite-0.5.11.tgz
|
|
35
|
+
stackbite
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The core source repository remains private at `Shinick-Han/helm`. The separate
|
|
39
|
+
public `Shinick-Han/stackbite` repository distributes only this thin launcher and
|
|
40
|
+
verified compiled runtime releases; installers need no GitHub or npm login.
|
|
41
|
+
Registry-free distribution does not require publishing rights on npm.
|
|
42
|
+
The small JavaScript package downloads the
|
|
43
|
+
exact pinned native release on first execution, verifies its archive and file
|
|
44
|
+
checksums and product identity, then reuses a persistent binary cache. There is no
|
|
45
|
+
postinstall download or provider installation. An offline cached launch is possible;
|
|
46
|
+
the first launch needs access to GitHub release downloads.
|
|
47
|
+
|
|
48
|
+
For a GitHub URL installation, install the package URL from the newer release.
|
|
49
|
+
Each npm package pins one native release; the portable app's own `stackbite update`
|
|
50
|
+
does not change the npm package version.
|
|
51
|
+
|
|
52
|
+
## Existing data and processes
|
|
53
|
+
|
|
54
|
+
`STACKBITE_STATE_DIR` is the explicit state override. The bundled native runtime
|
|
55
|
+
owns state discovery and migration for both npm and direct launches. It reuses
|
|
56
|
+
existing configured state and keeps live databases, authentication and records
|
|
57
|
+
in place. New state uses `%LOCALAPPDATA%\Stackbite\state` or
|
|
58
|
+
`${XDG_STATE_HOME:-~/.local/state}/stackbite`.
|
|
59
|
+
Rooms, Seats, authentication, provider configuration, journals and Work Stack
|
|
60
|
+
contracts are not renamed or migrated. Binary cache data is separate from this
|
|
61
|
+
state. Installing or launching the npm package does not stop an existing backend.
|
|
62
|
+
Check the connected server's capabilities before using newly added runtime features.
|
|
63
|
+
|
|
64
|
+
The launcher inherits terminal input/output and passes CLI arguments directly to
|
|
65
|
+
the native app. It does not insert a second screen renderer or an agent provider.
|
|
66
|
+
Global npm installation creates only the `stackbite` command; it does not claim an
|
|
67
|
+
external `helm` command or replace independent Kubernetes tooling.
|
|
68
|
+
|
|
69
|
+
## Maintainer release steps
|
|
70
|
+
|
|
71
|
+
Build and verify both native platform archives first. Prepare the pinned manifest
|
|
72
|
+
from their exact extracted bundles and archive bytes:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
python scripts/prepare_npm_release.py --version 0.5.11
|
|
76
|
+
npm test
|
|
77
|
+
npm pack
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Keep `package.json` and `packaging/npm/release.json` versions equal. Inspect `npm
|
|
81
|
+
pack --dry-run` before publishing; the package must include the launcher and pinned
|
|
82
|
+
manifest, and exclude native runtimes, installed state, credentials, tests and
|
|
83
|
+
development worktrees. Test the packed package with `npx` on both platforms.
|
|
84
|
+
|
|
85
|
+
Attach `stackbite-<version>.tgz` and its SHA-256 sidecar to the corresponding GitHub
|
|
86
|
+
release in the public distribution repository. Copy only the npm file allowlist to
|
|
87
|
+
that repository; never push the private core history, state, personal notes or
|
|
88
|
+
credentials. Native archives must retain dependency license notices. Publishing
|
|
89
|
+
the short name requires an authenticated owner account:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
npm login --registry=https://registry.npmjs.org
|
|
93
|
+
npm publish --access public --registry=https://registry.npmjs.org
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Complete any registry-required authentication interactively; do not put credentials
|
|
97
|
+
in this repository, prompts or package files. The repository currently specifies
|
|
98
|
+
`UNLICENSED` for the JavaScript wrapper instead of inventing a license for the app;
|
|
99
|
+
native runtime dependency notices remain in each platform archive.
|
package/package.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "stackbite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "stackbite",
|
|
3
|
+
"version": "0.5.11",
|
|
4
|
+
"description": "Verified native Stackbite CLI bootstrapper",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"engines": { "node": ">=20" },
|
|
7
|
+
"bin": { "stackbite": "packaging/npm/bin/stackbite.js" },
|
|
8
|
+
"files": ["packaging/npm/bin/", "packaging/npm/lib/", "packaging/npm/release.json"],
|
|
9
|
+
"scripts": { "test": "node --test packaging/npm/test/*.test.js" },
|
|
10
|
+
"repository": { "type": "git", "url": "https://github.com/Shinick-Han/stackbite.git" },
|
|
11
|
+
"homepage": "https://github.com/Shinick-Han/stackbite"
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const { main } = require('../lib/bootstrap');
|
|
4
|
+
main().then(({ code, signal }) => {
|
|
5
|
+
if (signal && process.platform !== 'win32') {
|
|
6
|
+
process.kill(process.pid, signal);
|
|
7
|
+
} else {
|
|
8
|
+
process.exitCode = code === null ? 1 : code;
|
|
9
|
+
}
|
|
10
|
+
}).catch(error => {
|
|
11
|
+
console.error(`Stackbite: ${error.message}`);
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const { createReadStream, createWriteStream } = require('node:fs');
|
|
4
|
+
const { createGunzip, createInflateRaw } = require('node:zlib');
|
|
5
|
+
const { Transform, Writable } = require('node:stream');
|
|
6
|
+
const { pipeline } = require('node:stream/promises');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { LIMITS, fail, memberPath } = require('./common');
|
|
10
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
11
|
+
const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
12
|
+
for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
|
|
13
|
+
return value >>> 0;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function readAt(file, offset, length) {
|
|
17
|
+
const bytes = Buffer.alloc(length);
|
|
18
|
+
const { bytesRead } = await file.read(bytes, 0, length, offset);
|
|
19
|
+
if (bytesRead !== length) fail('Truncated release archive');
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
function validateEntries(entries, root, windows) {
|
|
23
|
+
if (!entries.length || entries.length > LIMITS.members) fail('Invalid archive member count');
|
|
24
|
+
const names = new Map();
|
|
25
|
+
let expanded = 0;
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
entry.name = memberPath(entry.name);
|
|
28
|
+
if (entry.name !== root && !entry.name.startsWith(root + '/')) fail('Archive member is outside its pinned root');
|
|
29
|
+
const key = windows ? entry.name.toLowerCase() : entry.name;
|
|
30
|
+
if (names.has(key)) fail('Duplicate archive member');
|
|
31
|
+
names.set(key, entry);
|
|
32
|
+
expanded += entry.size;
|
|
33
|
+
if (entry.size < 0 || entry.size > LIMITS.member || expanded > LIMITS.expanded) fail('Archive expansion exceeds limit');
|
|
34
|
+
}
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
for (let parent = path.posix.dirname(entry.name); parent !== '.'; parent = path.posix.dirname(parent)) {
|
|
37
|
+
const ancestor = names.get(windows ? parent.toLowerCase() : parent);
|
|
38
|
+
if (ancestor && ancestor.kind !== 'directory') fail('Archive link/file is another member parent');
|
|
39
|
+
}
|
|
40
|
+
if (entry.kind === 'link') {
|
|
41
|
+
if (windows || !entry.link || entry.link.length > 1024 || /[\\:\x00-\x1f\x7f]/.test(entry.link)
|
|
42
|
+
|| path.posix.isAbsolute(entry.link)) fail('Unsafe archive link');
|
|
43
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(entry.name), entry.link));
|
|
44
|
+
const destination = names.get(target);
|
|
45
|
+
if (!target.startsWith(root + '/') || !destination || destination.kind !== 'file') fail('Archive link is escaping, dangling or chained');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
async function zipEntries(archive) {
|
|
51
|
+
const file = await fs.open(archive, 'r');
|
|
52
|
+
const signal = AbortSignal.timeout(LIMITS.timeout);
|
|
53
|
+
try {
|
|
54
|
+
const size = (await file.stat()).size;
|
|
55
|
+
const tailStart = Math.max(0, size - 65557);
|
|
56
|
+
const tail = await readAt(file, tailStart, size - tailStart);
|
|
57
|
+
const end = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
58
|
+
if (end < 0 || end + 22 > tail.length) fail('Invalid ZIP directory');
|
|
59
|
+
const count = tail.readUInt16LE(end + 10), directorySize = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
|
|
60
|
+
if (tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6) || tail.readUInt16LE(end + 8) !== count
|
|
61
|
+
|| count === 65535 || count > LIMITS.members || directorySize > 16 * 1024 * 1024
|
|
62
|
+
|| offset + directorySize !== tailStart + end || end + 22 + tail.readUInt16LE(end + 20) !== tail.length) fail('Unsafe ZIP directory');
|
|
63
|
+
const central = await readAt(file, offset, directorySize);
|
|
64
|
+
const entries = [], ranges = [];
|
|
65
|
+
let position = 0, totalExpanded = 0;
|
|
66
|
+
for (let index = 0; index < count; index++) {
|
|
67
|
+
signal.throwIfAborted();
|
|
68
|
+
if (position + 46 > central.length || central.readUInt32LE(position) !== 0x02014b50) fail('Invalid ZIP member');
|
|
69
|
+
const flags = central.readUInt16LE(position + 8), method = central.readUInt16LE(position + 10);
|
|
70
|
+
const compressed = central.readUInt32LE(position + 20), expanded = central.readUInt32LE(position + 24);
|
|
71
|
+
totalExpanded += expanded;
|
|
72
|
+
if (totalExpanded > LIMITS.expanded) fail('ZIP total expansion exceeds limit');
|
|
73
|
+
const nameSize = central.readUInt16LE(position + 28), extraSize = central.readUInt16LE(position + 30), commentSize = central.readUInt16LE(position + 32);
|
|
74
|
+
const localOffset = central.readUInt32LE(position + 42), mode = central.readUInt32LE(position + 38) >>> 16;
|
|
75
|
+
if (!nameSize || nameSize > 1024 || position + 46 + nameSize + extraSize + commentSize > central.length
|
|
76
|
+
|| central.readUInt16LE(position + 34) || (flags & ~0x080e) || ![0, 8].includes(method)
|
|
77
|
+
|| expanded > LIMITS.member || ![0, 0o100000, 0o040000].includes(mode & 0o170000) || (mode & 0o7000)) fail('Unsupported ZIP member');
|
|
78
|
+
const rawName = central.subarray(position + 46, position + 46 + nameSize);
|
|
79
|
+
if (!(flags & 0x0800) && rawName.some(value => value > 127)) fail('Ambiguous ZIP filename encoding');
|
|
80
|
+
const name = utf8.decode(rawName);
|
|
81
|
+
const directory = name.endsWith('/');
|
|
82
|
+
if (directory && (expanded || compressed)) fail('Invalid ZIP directory member');
|
|
83
|
+
const directoryMode = (mode & 0o170000) === 0o040000 || !!(central.readUInt32LE(position + 38) & 16);
|
|
84
|
+
if (directoryMode && !directory) fail('ZIP directory identity mismatch');
|
|
85
|
+
const local = await readAt(file, localOffset, 30);
|
|
86
|
+
const localNameSize = local.readUInt16LE(26), localExtraSize = local.readUInt16LE(28);
|
|
87
|
+
const dataStart = localOffset + 30 + localNameSize + localExtraSize;
|
|
88
|
+
if (local.readUInt32LE(0) !== 0x04034b50 || local.readUInt16LE(6) !== flags || local.readUInt16LE(8) !== method
|
|
89
|
+
|| localNameSize !== nameSize || dataStart + compressed > offset
|
|
90
|
+
|| !(await readAt(file, localOffset + 30, localNameSize)).equals(rawName)) fail('ZIP local/central identity mismatch');
|
|
91
|
+
if (!(flags & 8) && (local.readUInt32LE(14) !== central.readUInt32LE(position + 16)
|
|
92
|
+
|| local.readUInt32LE(18) !== compressed || local.readUInt32LE(22) !== expanded)) fail('ZIP local size mismatch');
|
|
93
|
+
if (!directory) {
|
|
94
|
+
// Bound the actual inflated bytes, not only the central-directory claim,
|
|
95
|
+
// before handing the same archive to the native extraction command.
|
|
96
|
+
if (method === 0 && compressed !== expanded) fail('Invalid stored ZIP size');
|
|
97
|
+
let actualSize = 0, crc = 0xffffffff;
|
|
98
|
+
const sink = new Writable({ write(chunk, encoding, callback) {
|
|
99
|
+
actualSize += chunk.length;
|
|
100
|
+
if (actualSize > expanded) return callback(new Error('ZIP expansion exceeds declared size'));
|
|
101
|
+
for (const byte of chunk) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 255];
|
|
102
|
+
callback();
|
|
103
|
+
} });
|
|
104
|
+
if (compressed) {
|
|
105
|
+
const input = createReadStream(archive, { start: dataStart, end: dataStart + compressed - 1 });
|
|
106
|
+
if (method === 8) await pipeline(input, createInflateRaw(), sink, { signal });
|
|
107
|
+
else await pipeline(input, sink, { signal });
|
|
108
|
+
}
|
|
109
|
+
if (actualSize !== expanded || ((crc ^ 0xffffffff) >>> 0) !== central.readUInt32LE(position + 16)) fail('ZIP content size/CRC mismatch');
|
|
110
|
+
}
|
|
111
|
+
// Reject Unicode-path/ZIP64 extra fields: native tar must use the same name
|
|
112
|
+
// and sizes checked here, without a second interpretation of this entry.
|
|
113
|
+
for (const extra of [central.subarray(position + 46 + nameSize, position + 46 + nameSize + extraSize),
|
|
114
|
+
await readAt(file, localOffset + 30 + localNameSize, localExtraSize)]) {
|
|
115
|
+
for (let at = 0; at < extra.length;) {
|
|
116
|
+
if (at + 4 > extra.length) fail('Invalid ZIP extra field');
|
|
117
|
+
const kind = extra.readUInt16LE(at), length = extra.readUInt16LE(at + 2);
|
|
118
|
+
if (![0x5455, 0x7875].includes(kind) || at + 4 + length > extra.length) fail('Unsupported ZIP extension');
|
|
119
|
+
at += 4 + length;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
ranges.push([localOffset, dataStart + compressed]);
|
|
123
|
+
entries.push({ name, kind: directory ? 'directory' : 'file', size: expanded });
|
|
124
|
+
position += 46 + nameSize + extraSize + commentSize;
|
|
125
|
+
}
|
|
126
|
+
if (position !== central.length) fail('Extra ZIP directory records');
|
|
127
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
128
|
+
if (ranges.some((range, index) => index && range[0] < ranges[index - 1][1])) fail('Overlapping ZIP members');
|
|
129
|
+
return entries;
|
|
130
|
+
} finally { await file.close(); }
|
|
131
|
+
}
|
|
132
|
+
function tarNumber(bytes) {
|
|
133
|
+
const value = bytes.toString('ascii').replace(/\0.*$/, '').trim();
|
|
134
|
+
if (value && !/^[0-7]+$/.test(value)) fail('Unsupported TAR numeric field');
|
|
135
|
+
const result = Number.parseInt(value || '0', 8);
|
|
136
|
+
if (!Number.isSafeInteger(result)) fail('Invalid TAR size');
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
function tarText(bytes) { return utf8.decode(bytes.subarray(0, bytes.indexOf(0) < 0 ? bytes.length : bytes.indexOf(0))); }
|
|
140
|
+
function paxFields(bytes) {
|
|
141
|
+
const fields = {};
|
|
142
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
143
|
+
const space = bytes.indexOf(32, offset);
|
|
144
|
+
if (space < 0 || !/^\d+$/.test(bytes.subarray(offset, space).toString())) fail('Invalid TAR PAX length');
|
|
145
|
+
const length = Number(bytes.subarray(offset, space).toString());
|
|
146
|
+
if (!Number.isSafeInteger(length) || length <= space - offset + 2 || offset + length > bytes.length || bytes[offset + length - 1] !== 10) fail('Invalid TAR PAX record');
|
|
147
|
+
const record = utf8.decode(bytes.subarray(space + 1, offset + length - 1));
|
|
148
|
+
const equal = record.indexOf('=');
|
|
149
|
+
const key = record.slice(0, equal), value = record.slice(equal + 1);
|
|
150
|
+
if (equal < 1 || !['path', 'linkpath', 'mtime', 'atime', 'ctime'].includes(key) || Object.hasOwn(fields, key)) fail('Unsupported TAR PAX field');
|
|
151
|
+
fields[key] = value;
|
|
152
|
+
offset += length;
|
|
153
|
+
}
|
|
154
|
+
return fields;
|
|
155
|
+
}
|
|
156
|
+
async function tarEntries(archive) {
|
|
157
|
+
const file = await fs.open(archive, 'r');
|
|
158
|
+
try {
|
|
159
|
+
const total = (await file.stat()).size;
|
|
160
|
+
const entries = [];
|
|
161
|
+
let offset = 0, pax = null, headers = 0;
|
|
162
|
+
while (offset + 512 <= total) {
|
|
163
|
+
const header = await readAt(file, offset, 512);
|
|
164
|
+
offset += 512;
|
|
165
|
+
if (header.every(value => value === 0)) {
|
|
166
|
+
if (pax || offset + 512 > total) fail('Truncated TAR trailer');
|
|
167
|
+
for (let at = offset; at < total; at += 65536) {
|
|
168
|
+
if (!(await readAt(file, at, Math.min(65536, total - at))).every(value => value === 0)) fail('Extra TAR payload after trailer');
|
|
169
|
+
}
|
|
170
|
+
return entries;
|
|
171
|
+
}
|
|
172
|
+
if (++headers > LIMITS.members * 2) fail('Too many TAR headers');
|
|
173
|
+
if (header.subarray(257, 263).toString('ascii') !== 'ustar\0') fail('Unsupported TAR header format');
|
|
174
|
+
let checksum = 0;
|
|
175
|
+
for (let index = 0; index < 512; index++) checksum += index >= 148 && index < 156 ? 32 : header[index];
|
|
176
|
+
if (checksum !== tarNumber(header.subarray(148, 156))) fail('Invalid TAR checksum');
|
|
177
|
+
const size = tarNumber(header.subarray(124, 136));
|
|
178
|
+
const kind = String.fromCharCode(header[156]);
|
|
179
|
+
if (size > (kind === 'x' ? 65536 : LIMITS.member) || offset + Math.ceil(size / 512) * 512 > total
|
|
180
|
+
|| tarNumber(header.subarray(100, 108)) & 0o7000) fail('Unsafe TAR size or permissions');
|
|
181
|
+
if (kind === 'x') {
|
|
182
|
+
if (pax) fail('Chained TAR PAX headers');
|
|
183
|
+
pax = paxFields(await readAt(file, offset, size));
|
|
184
|
+
} else {
|
|
185
|
+
if (!['\0', '0', '5', '2'].includes(kind) || (kind !== '0' && kind !== '\0' && size)) fail('Unsupported TAR member');
|
|
186
|
+
const prefix = tarText(header.subarray(345, 500));
|
|
187
|
+
const name = pax && Object.hasOwn(pax, 'path') ? pax.path : (prefix ? prefix + '/' : '') + tarText(header.subarray(0, 100));
|
|
188
|
+
entries.push({ name, kind: kind === '5' ? 'directory' : kind === '2' ? 'link' : 'file', size,
|
|
189
|
+
link: pax && Object.hasOwn(pax, 'linkpath') ? pax.linkpath : tarText(header.subarray(157, 257)) });
|
|
190
|
+
pax = null;
|
|
191
|
+
}
|
|
192
|
+
offset += Math.ceil(size / 512) * 512;
|
|
193
|
+
}
|
|
194
|
+
fail('TAR trailer is missing');
|
|
195
|
+
} finally { await file.close(); }
|
|
196
|
+
}
|
|
197
|
+
async function boundedGunzip(archive, destination) {
|
|
198
|
+
let size = 0;
|
|
199
|
+
const bounded = new Transform({ transform(chunk, encoding, callback) {
|
|
200
|
+
size += chunk.length;
|
|
201
|
+
callback(size > LIMITS.expanded ? new Error('TAR expansion exceeds limit') : null, chunk);
|
|
202
|
+
} });
|
|
203
|
+
await pipeline(createReadStream(archive), createGunzip(), bounded,
|
|
204
|
+
createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal: AbortSignal.timeout(LIMITS.timeout) });
|
|
205
|
+
}
|
|
206
|
+
function nativeTar() {
|
|
207
|
+
return process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : '/usr/bin/tar';
|
|
208
|
+
}
|
|
209
|
+
async function extractArchive(archive, destination, spec, { spawnImpl = spawn } = {}) {
|
|
210
|
+
let input = archive;
|
|
211
|
+
const windows = spec.asset.endsWith('.zip');
|
|
212
|
+
if (!windows) { input = archive + '.tar'; await boundedGunzip(archive, input); }
|
|
213
|
+
const entries = windows ? await zipEntries(input) : await tarEntries(input);
|
|
214
|
+
validateEntries(entries, spec.root, windows);
|
|
215
|
+
await new Promise((resolve, reject) => {
|
|
216
|
+
const child = spawnImpl(nativeTar(), ['-xf', input, '-C', destination, '--no-same-owner', '--no-same-permissions'],
|
|
217
|
+
{ shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
|
|
218
|
+
let errorBytes = 0;
|
|
219
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('Native tar extraction timed out')); }, LIMITS.timeout);
|
|
220
|
+
child.stderr.on('data', chunk => { errorBytes += chunk.length; if (errorBytes > 65536) { child.kill(); } });
|
|
221
|
+
child.once('error', () => { clearTimeout(timer); reject(new Error('Native tar is unavailable; install the operating system tar command')); });
|
|
222
|
+
child.once('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('Native tar rejected the release archive')); });
|
|
223
|
+
});
|
|
224
|
+
return entries;
|
|
225
|
+
}
|
|
226
|
+
module.exports = { validateEntries, zipEntries, tarEntries, extractArchive, nativeTar };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { spawn } = require('node:child_process');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists, safeDirectory } = require('./common');
|
|
7
|
+
const { download } = require('./download');
|
|
8
|
+
const { extractArchive } = require('./archive');
|
|
9
|
+
const { cacheDirectory } = require('./cache');
|
|
10
|
+
const packageVersion = require('../../../package.json').version;
|
|
11
|
+
|
|
12
|
+
function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}) {
|
|
13
|
+
const key = `${platform}-${arch}`;
|
|
14
|
+
if (!['win32-x64', 'linux-x64'].includes(key)) fail(`Stackbite does not have a native release for ${key}; supported platforms are Windows x64 and Linux x64.`);
|
|
15
|
+
if (platform === 'linux' && (process.platform === 'linux' || Object.hasOwn(runtime, 'glibcVersionRuntime'))) {
|
|
16
|
+
const glibc = Object.hasOwn(runtime, 'glibcVersionRuntime')
|
|
17
|
+
? runtime.glibcVersionRuntime : process.report.getReport().header.glibcVersionRuntime;
|
|
18
|
+
if (typeof glibc !== 'string' || !/^\d+\.\d+(?:\.\d+)?$/.test(glibc)) {
|
|
19
|
+
fail('Stackbite requires Linux x64 with glibc; Alpine/musl Linux is unsupported. No release was downloaded.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (manifest.schema !== 1 || manifest.version !== packageVersion || !/^[a-f0-9]{40}$/.test(manifest.commit)
|
|
23
|
+
|| manifest.repository !== 'Shinick-Han/stackbite') fail('Invalid pinned Stackbite release identity');
|
|
24
|
+
const spec = manifest.platforms?.[key];
|
|
25
|
+
const host = platform === 'win32' ? 'windows-x64' : 'linux-x86_64';
|
|
26
|
+
const root = `stackbite-${manifest.version}-${host}-${manifest.commit.slice(0, 12)}`;
|
|
27
|
+
const asset = root + (platform === 'win32' ? '.zip' : '.tar.gz');
|
|
28
|
+
if (!spec || spec.root !== root || spec.asset !== asset
|
|
29
|
+
|| spec.url !== `https://github.com/${manifest.repository}/releases/download/v${manifest.version}/${asset}`
|
|
30
|
+
|| ![spec.sha256, spec.manifestSha256, spec.metadataSha256].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))
|
|
31
|
+
|| !Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned release asset');
|
|
32
|
+
return { ...spec, key, platform, host, version: manifest.version, commit: manifest.commit };
|
|
33
|
+
}
|
|
34
|
+
async function validatePayload(directory, spec) {
|
|
35
|
+
const info = await fs.lstat(directory);
|
|
36
|
+
if (!info.isDirectory() || info.isSymbolicLink()) fail('Cached release root is not a regular directory');
|
|
37
|
+
const root = await fs.realpath(directory);
|
|
38
|
+
const manifestPath = path.join(root, 'SHA256SUMS.json'), metadataPath = path.join(root, 'portable.json');
|
|
39
|
+
const manifestBytes = await readSmall(manifestPath), metadataBytes = await readSmall(metadataPath, 16384);
|
|
40
|
+
if (createHash('sha256').update(manifestBytes).digest('hex') !== spec.manifestSha256
|
|
41
|
+
|| createHash('sha256').update(metadataBytes).digest('hex') !== spec.metadataSha256) fail('Pinned release manifest or metadata SHA-256 mismatch');
|
|
42
|
+
const hashes = JSON.parse(manifestBytes.toString('utf8'));
|
|
43
|
+
const metadata = JSON.parse(metadataBytes.toString('utf8'));
|
|
44
|
+
if (!hashes || Array.isArray(hashes) || typeof hashes !== 'object' || !Object.keys(hashes).length
|
|
45
|
+
|| Object.keys(hashes).length > LIMITS.members) fail('Invalid release file manifest');
|
|
46
|
+
const expected = new Set();
|
|
47
|
+
for (const [name, digest] of Object.entries(hashes)) {
|
|
48
|
+
if (memberPath(name) !== name || !/^[a-f0-9]{64}$/.test(digest) || ['SHA256SUMS.json', 'SHA256SUMS'].includes(name)) fail('Invalid release manifest member');
|
|
49
|
+
expected.add(name);
|
|
50
|
+
}
|
|
51
|
+
if (!expected.has('portable.json') || !expected.has('runtime/build-info.json')) fail('Required release metadata is missing');
|
|
52
|
+
const actual = new Set();
|
|
53
|
+
let total = 0, entries = 0;
|
|
54
|
+
async function visit(directoryPath) {
|
|
55
|
+
for (const name of await fs.readdir(directoryPath)) {
|
|
56
|
+
if (++entries > LIMITS.members * 2) fail('Cached release has too many entries');
|
|
57
|
+
const target = path.join(directoryPath, name), relative = path.relative(root, target).split(path.sep).join('/');
|
|
58
|
+
memberPath(relative);
|
|
59
|
+
const entry = await fs.lstat(target);
|
|
60
|
+
if (entry.isDirectory()) { await visit(target); continue; }
|
|
61
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) fail('Release contains a special file');
|
|
62
|
+
const resolved = await fs.realpath(target);
|
|
63
|
+
if (!inside(root, resolved)) fail('Release link escapes the cache root');
|
|
64
|
+
const resolvedInfo = await fs.lstat(resolved);
|
|
65
|
+
if (!resolvedInfo.isFile() || resolvedInfo.size > LIMITS.member) fail('Release link does not resolve to a bounded regular file');
|
|
66
|
+
total += resolvedInfo.size;
|
|
67
|
+
if (total > LIMITS.expanded) fail('Cached release exceeds expansion limit');
|
|
68
|
+
actual.add(relative);
|
|
69
|
+
if (expected.has(relative) && await hashFile(resolved) !== hashes[relative]) fail(`Release file SHA-256 mismatch: ${relative}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await visit(root);
|
|
73
|
+
expected.add('SHA256SUMS.json');
|
|
74
|
+
if (spec.platform === 'linux') {
|
|
75
|
+
expected.add('SHA256SUMS');
|
|
76
|
+
const text = await readSmall(path.join(root, 'SHA256SUMS'));
|
|
77
|
+
if (text.toString('utf8') !== Object.entries(hashes).map(([name, digest]) => `${digest} ${name}\n`).join('')) fail('Linux checksum text disagrees with pinned manifest');
|
|
78
|
+
}
|
|
79
|
+
if (actual.size !== expected.size || [...actual].some(name => !expected.has(name))) fail('Release files disagree with pinned manifest');
|
|
80
|
+
const build = JSON.parse((await readSmall(path.join(root, 'runtime', 'build-info.json'), 16384)).toString('utf8'));
|
|
81
|
+
for (const value of [metadata, build]) {
|
|
82
|
+
if (value.version !== spec.version || value.commit !== spec.commit || value.product !== 'Stackbite'
|
|
83
|
+
|| (value.command !== undefined && value.command !== 'stackbite')
|
|
84
|
+
|| (value.platform !== undefined && value.platform !== spec.host)) fail('Release product/version/commit identity mismatch');
|
|
85
|
+
}
|
|
86
|
+
const executable = path.join(root, spec.platform === 'win32' ? 'stackbite.exe' : 'stackbite');
|
|
87
|
+
const image = await fs.lstat(executable);
|
|
88
|
+
if (!image.isFile() || image.isSymbolicLink()) fail('Native release executable is missing or a link');
|
|
89
|
+
const file = await fs.open(executable, 'r');
|
|
90
|
+
try {
|
|
91
|
+
const magic = Buffer.alloc(4);
|
|
92
|
+
if ((await file.read(magic, 0, 4, 0)).bytesRead !== 4
|
|
93
|
+
|| !(spec.platform === 'win32' ? magic.subarray(0, 2).equals(Buffer.from('MZ')) : magic.equals(Buffer.from([0x7f, 69, 76, 70])))) fail('Invalid native executable identity');
|
|
94
|
+
} finally { await file.close(); }
|
|
95
|
+
if (spec.platform === 'linux' && process.platform !== 'win32' && !(image.mode & 0o111)) fail('Native release executable has no execute permission');
|
|
96
|
+
return executable;
|
|
97
|
+
}
|
|
98
|
+
async function ensureBinary(spec, { cache = cacheDirectory(), downloadImpl = download, extractImpl = extractArchive } = {}) {
|
|
99
|
+
cache = path.resolve(cache);
|
|
100
|
+
await safeDirectory(cache);
|
|
101
|
+
const target = path.join(cache, `${spec.version}-${spec.commit}-${spec.key}-${spec.sha256}`);
|
|
102
|
+
if (await exists(target)) return { home: target, executable: await validatePayload(target, spec) };
|
|
103
|
+
const staging = await fs.mkdtemp(path.join(cache, '.staging-'));
|
|
104
|
+
try {
|
|
105
|
+
const archive = path.join(staging, spec.asset), extraction = path.join(staging, 'extract');
|
|
106
|
+
await fs.mkdir(extraction, { mode: 0o700 });
|
|
107
|
+
await downloadImpl(spec, archive);
|
|
108
|
+
// Recheck at the extraction boundary, including for injected test transports.
|
|
109
|
+
if ((await fs.stat(archive)).size !== spec.size || await hashFile(archive, LIMITS.download) !== spec.sha256) fail('Pinned archive SHA-256 or size mismatch');
|
|
110
|
+
await extractImpl(archive, extraction, spec);
|
|
111
|
+
const payload = path.join(extraction, spec.root);
|
|
112
|
+
await validatePayload(payload, spec);
|
|
113
|
+
try { await fs.rename(payload, target); }
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Another process may have installed the same immutable payload first.
|
|
116
|
+
// Never delete/rewrite its directory or try a destructive rename retry.
|
|
117
|
+
if (!await exists(target)) throw error;
|
|
118
|
+
}
|
|
119
|
+
return { home: target, executable: await validatePayload(target, spec) };
|
|
120
|
+
} finally {
|
|
121
|
+
// Only this mkdtemp handle is owned by this invocation.
|
|
122
|
+
if (!inside(cache, staging) || path.dirname(staging) !== cache) fail('Unsafe staging cleanup path');
|
|
123
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function launchNative(executable, argv, environment, { spawnImpl = spawn, signalSource = process } = {}) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const child = spawnImpl(executable, argv, { env: environment, stdio: 'inherit', shell: false });
|
|
129
|
+
const handlers = new Map();
|
|
130
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
131
|
+
const handler = () => child.kill(signal);
|
|
132
|
+
handlers.set(signal, handler);
|
|
133
|
+
signalSource.on(signal, handler);
|
|
134
|
+
}
|
|
135
|
+
function cleanup() { for (const [signal, handler] of handlers) signalSource.removeListener(signal, handler); }
|
|
136
|
+
child.once('error', () => { cleanup(); reject(new Error('Cannot start the verified native Stackbite executable')); });
|
|
137
|
+
child.once('exit', (code, signal) => { cleanup(); resolve({ code, signal }); });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async function main(argv = process.argv.slice(2), options = {}) {
|
|
141
|
+
const manifest = options.manifest || require('../release.json');
|
|
142
|
+
const platform = options.platform || process.platform;
|
|
143
|
+
const environment = options.environment || process.env;
|
|
144
|
+
const spec = releaseSpec(manifest, platform, options.arch || process.arch, options);
|
|
145
|
+
const installed = await ensureBinary(spec, { ...options, cache: options.cache || cacheDirectory(environment, platform) });
|
|
146
|
+
// The bundled runtime owns state discovery and migration for every entrypoint.
|
|
147
|
+
return launchNative(installed.executable, argv, { ...environment,
|
|
148
|
+
STACKBITE_HOME: path.resolve(installed.home) }, options);
|
|
149
|
+
}
|
|
150
|
+
module.exports = { releaseSpec, validatePayload, ensureBinary, launchNative, main };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
|
|
5
|
+
function cacheDirectory(environment = process.env, platform = process.platform, home = os.homedir()) {
|
|
6
|
+
return platform === 'win32'
|
|
7
|
+
? path.resolve(environment.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Stackbite', 'cache')
|
|
8
|
+
: path.resolve(environment.XDG_CACHE_HOME || path.join(environment.HOME || home, '.cache'), 'stackbite');
|
|
9
|
+
}
|
|
10
|
+
module.exports = { cacheDirectory };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const { createReadStream } = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const LIMITS = Object.freeze({ download: 512 * 1024 * 1024, expanded: 1024 * 1024 * 1024,
|
|
8
|
+
member: 128 * 1024 * 1024, metadata: 2 * 1024 * 1024, members: 25000, timeout: 120000 });
|
|
9
|
+
function fail(message) { throw new Error(message); }
|
|
10
|
+
function memberPath(name) {
|
|
11
|
+
if (typeof name !== 'string' || !name || name.length > 1024 || /[\\:<>"|?*\x00-\x1f\x7f]/.test(name)) fail('Unsafe archive path');
|
|
12
|
+
const cleaned = name.endsWith('/') ? name.slice(0, -1) : name;
|
|
13
|
+
const parts = cleaned.split('/');
|
|
14
|
+
if (parts.some(part => !part || part === '.' || part === '..' || /[ .]$/.test(part)
|
|
15
|
+
|| /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(part))) fail('Unsafe archive path');
|
|
16
|
+
return cleaned;
|
|
17
|
+
}
|
|
18
|
+
function inside(root, target) {
|
|
19
|
+
const relative = path.relative(root, target);
|
|
20
|
+
return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
|
|
21
|
+
}
|
|
22
|
+
async function hashFile(filename, maximum = LIMITS.member) {
|
|
23
|
+
const info = await fs.stat(filename);
|
|
24
|
+
if (!info.isFile() || info.size > maximum) fail('Release file exceeds its size limit');
|
|
25
|
+
const hash = createHash('sha256');
|
|
26
|
+
let size = 0;
|
|
27
|
+
for await (const chunk of createReadStream(filename)) {
|
|
28
|
+
size += chunk.length;
|
|
29
|
+
if (size > maximum) fail('Release file exceeds its size limit');
|
|
30
|
+
hash.update(chunk);
|
|
31
|
+
}
|
|
32
|
+
return hash.digest('hex');
|
|
33
|
+
}
|
|
34
|
+
async function readSmall(filename, maximum = LIMITS.metadata) {
|
|
35
|
+
const info = await fs.lstat(filename);
|
|
36
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > maximum) fail('Unsafe or oversized release metadata');
|
|
37
|
+
return fs.readFile(filename);
|
|
38
|
+
}
|
|
39
|
+
async function exists(filename) {
|
|
40
|
+
try { await fs.lstat(filename); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
41
|
+
}
|
|
42
|
+
async function safeDirectory(directory) {
|
|
43
|
+
const parent = path.dirname(directory);
|
|
44
|
+
if (parent !== directory) await safeDirectory(parent);
|
|
45
|
+
if (!await exists(directory)) {
|
|
46
|
+
try { await fs.mkdir(directory, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
47
|
+
}
|
|
48
|
+
const info = await fs.lstat(directory);
|
|
49
|
+
if (!info.isDirectory() || info.isSymbolicLink()) fail('Cache directory contains a link or non-directory');
|
|
50
|
+
}
|
|
51
|
+
module.exports = { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists, safeDirectory };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const https = require('node:https');
|
|
3
|
+
const { createWriteStream } = require('node:fs');
|
|
4
|
+
const { Transform } = require('node:stream');
|
|
5
|
+
const { pipeline } = require('node:stream/promises');
|
|
6
|
+
const { LIMITS, fail, hashFile } = require('./common');
|
|
7
|
+
|
|
8
|
+
function secureURL(value) {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (url.protocol !== 'https:' || url.username || url.password || (url.port && url.port !== '443')
|
|
11
|
+
|| !['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com'].includes(url.hostname)) {
|
|
12
|
+
fail('Release redirect is not an approved HTTPS origin');
|
|
13
|
+
}
|
|
14
|
+
return url;
|
|
15
|
+
}
|
|
16
|
+
async function download(spec, destination, { request = https.get, timeout = LIMITS.timeout } = {}) {
|
|
17
|
+
if (!Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned download size');
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
20
|
+
let current = secureURL(spec.url);
|
|
21
|
+
try {
|
|
22
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
23
|
+
const response = await new Promise((resolve, reject) => {
|
|
24
|
+
const req = request(current, { signal: controller.signal, headers: {
|
|
25
|
+
'User-Agent': 'Stackbite-npm-bootstrap', 'Accept-Encoding': 'identity', Accept: 'application/octet-stream'
|
|
26
|
+
} }, resolve);
|
|
27
|
+
req.once('error', () => reject(new Error('Release download failed or timed out')));
|
|
28
|
+
});
|
|
29
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
|
|
30
|
+
const location = response.headers.location;
|
|
31
|
+
response.destroy();
|
|
32
|
+
if (redirects === 5 || typeof location !== 'string' || location.length > 4096) fail('Invalid release redirect');
|
|
33
|
+
current = secureURL(new URL(location, current).href);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (response.statusCode !== 200 || (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity')) {
|
|
37
|
+
response.destroy(); fail('Release download was not available');
|
|
38
|
+
}
|
|
39
|
+
const length = response.headers['content-length'];
|
|
40
|
+
if (length !== undefined && String(spec.size) !== length) { response.destroy(); fail('Release download size mismatch'); }
|
|
41
|
+
let size = 0;
|
|
42
|
+
const bounded = new Transform({ transform(chunk, encoding, callback) {
|
|
43
|
+
size += chunk.length;
|
|
44
|
+
callback(size > spec.size ? new Error('Release download exceeds pinned size') : null, chunk);
|
|
45
|
+
} });
|
|
46
|
+
await pipeline(response, bounded, createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal: controller.signal });
|
|
47
|
+
if (size !== spec.size || await hashFile(destination, LIMITS.download) !== spec.sha256) fail('Release SHA-256 or size mismatch');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
} finally { clearTimeout(timer); }
|
|
51
|
+
}
|
|
52
|
+
module.exports = { download, secureURL };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"version": "0.5.11",
|
|
4
|
+
"commit": "572562d1755f055d2ec343ec431876a2d2838eec",
|
|
5
|
+
"repository": "Shinick-Han/stackbite",
|
|
6
|
+
"platforms": {
|
|
7
|
+
"win32-x64": {
|
|
8
|
+
"asset": "stackbite-0.5.11-windows-x64-572562d1755f.zip",
|
|
9
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.11/stackbite-0.5.11-windows-x64-572562d1755f.zip",
|
|
10
|
+
"sha256": "757619ad45c8f20b76e6c2b67e061415d7894e8d8e1cb9ce215a427dcc5c1b8f",
|
|
11
|
+
"size": 35311793,
|
|
12
|
+
"root": "stackbite-0.5.11-windows-x64-572562d1755f",
|
|
13
|
+
"manifestSha256": "179ced3cf7c7cd8aebc9fec5244baa978152730da8d894812e7b4587630e264b",
|
|
14
|
+
"metadataSha256": "d783e1f17dd31a43521b9b78b62a7c13f3e0193fe07e23b27e078038596a9589"
|
|
15
|
+
},
|
|
16
|
+
"linux-x64": {
|
|
17
|
+
"asset": "stackbite-0.5.11-linux-x86_64-572562d1755f.tar.gz",
|
|
18
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.11/stackbite-0.5.11-linux-x86_64-572562d1755f.tar.gz",
|
|
19
|
+
"sha256": "500927a33edb6ec567b7b0d20ff40ad9f24646b52d33eebeb23f550907554f6c",
|
|
20
|
+
"size": 18475500,
|
|
21
|
+
"root": "stackbite-0.5.11-linux-x86_64-572562d1755f",
|
|
22
|
+
"manifestSha256": "7b8487f4f29b36d9d584d981dc57dc96da991c2951bd808c67fb158ae1239eba",
|
|
23
|
+
"metadataSha256": "0490bde5afb5279516b254eb43b25bf228def181cfd4a83cc2c4251993718c8b"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|