workbite 0.1.21 → 0.1.24
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 +41 -31
- package/bin/workbite.js +13 -13
- package/lib/agent.js +240 -240
- package/lib/archive.js +334 -334
- package/lib/boot-provenance.js +54 -54
- package/lib/cli.js +334 -334
- package/lib/common.js +68 -68
- package/lib/diagnostics.js +238 -238
- package/lib/download.js +53 -53
- package/lib/install.js +274 -274
- package/lib/mcp-task-context.js +188 -188
- package/lib/mcp.js +671 -671
- package/lib/private-state.js +116 -116
- package/lib/runtime.js +775 -775
- package/lib/startup-lock.js +71 -71
- package/lib/transfer.js +113 -113
- package/lib/update.js +86 -86
- package/package.json +35 -35
- package/release.json +13 -13
- package/skills/workbite-native/SKILL.md +126 -126
- package/skills/workbite-native/agents/openai.yaml +4 -4
- package/skills/workbite-native/references/commands.md +213 -213
- package/skills/workbite-native/references/task-fields.md +208 -208
- package/skills/workbite-native/references/transfer.md +204 -204
package/lib/install.js
CHANGED
|
@@ -1,274 +1,274 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const fs = require('node:fs/promises');
|
|
3
|
-
const path = require('node:path');
|
|
4
|
-
const os = require('node:os');
|
|
5
|
-
const { performance } = require('node:perf_hooks');
|
|
6
|
-
const { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists, safeDirectory, sha256 } = require('./common');
|
|
7
|
-
const { download } = require('./download');
|
|
8
|
-
const { extractArchive, diagnosticText, TOP_DIRECTORIES, BUNDLE } = require('./archive');
|
|
9
|
-
const packageVersion = require('../package.json').version;
|
|
10
|
-
|
|
11
|
-
const REPOSITORY = 'Shinick-Han/WorkBite-releases';
|
|
12
|
-
const TARGETS = Object.freeze({ 'win32-x64': 'windows-x64', 'linux-x64': 'linux-x64' });
|
|
13
|
-
const EXECUTABLES = Object.freeze({
|
|
14
|
-
workstack_engine: 'workstack/workstack-native-engine',
|
|
15
|
-
workstack_client: 'workstack/workstack-native-client',
|
|
16
|
-
workstack_tui: 'workstack/workstack-tui',
|
|
17
|
-
stackbite: 'stackbite/stackbite',
|
|
18
|
-
});
|
|
19
|
-
// Exact executable roster per bundle schema: v1 keeps the four legacy entries; v2 adds only the
|
|
20
|
-
// same-window execution UI (proprietary assembly binary workstack-native-tui). Any other schema is refused.
|
|
21
|
-
const BUNDLE_V1 = 'workbite.bundle.v1';
|
|
22
|
-
const BUNDLE_V2 = 'workbite.bundle.v2';
|
|
23
|
-
const ROSTERS = Object.freeze({
|
|
24
|
-
[BUNDLE_V1]: EXECUTABLES,
|
|
25
|
-
[BUNDLE_V2]: Object.freeze({ ...EXECUTABLES, workbite_tui: 'workstack/workstack-native-tui' }),
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
function absoluteOverride(environment, name) {
|
|
29
|
-
const value = environment[name];
|
|
30
|
-
if (value === undefined || value === '') return undefined;
|
|
31
|
-
if (!path.isAbsolute(value) || /[\x00-\x1f]/.test(value)) fail(`${name} must be an absolute path`);
|
|
32
|
-
return path.resolve(value);
|
|
33
|
-
}
|
|
34
|
-
// Per-user locations owned by WorkBite only; existing Work Stack/StackBite locations are never used.
|
|
35
|
-
function locations(environment = process.env, platform = process.platform, home = os.homedir()) {
|
|
36
|
-
const windows = platform === 'win32';
|
|
37
|
-
const local = windows ? (environment.LOCALAPPDATA || path.join(home, 'AppData', 'Local')) : undefined;
|
|
38
|
-
const data = absoluteOverride(environment, 'WORKBITE_HOME') || (windows ? path.resolve(local, 'WorkBite')
|
|
39
|
-
: path.resolve(environment.XDG_DATA_HOME || path.join(environment.HOME || home, '.local', 'share'), 'workbite'));
|
|
40
|
-
const state = absoluteOverride(environment, 'WORKBITE_STATE_DIR') || (windows ? path.resolve(local, 'WorkBite', 'state')
|
|
41
|
-
: path.resolve(environment.XDG_STATE_HOME || path.join(environment.HOME || home, '.local', 'state'), 'workbite'));
|
|
42
|
-
return { apps: path.join(data, 'app'), state };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function platformTarget(platform = process.platform, arch = process.arch, runtime = {}) {
|
|
46
|
-
const key = `${platform}-${arch}`;
|
|
47
|
-
const target = TARGETS[key];
|
|
48
|
-
if (!target) fail(`WorkBite has no native release for ${key}; supported platforms are Windows x64 and Linux x64 (glibc 2.28+). Nothing was downloaded.`);
|
|
49
|
-
if (platform === 'linux' && (process.platform === 'linux' || Object.hasOwn(runtime, 'glibcVersionRuntime'))) {
|
|
50
|
-
const glibc = Object.hasOwn(runtime, 'glibcVersionRuntime')
|
|
51
|
-
? runtime.glibcVersionRuntime : process.report.getReport().header.glibcVersionRuntime;
|
|
52
|
-
const match = typeof glibc === 'string' ? /^(\d+)\.(\d+)/.exec(glibc) : null;
|
|
53
|
-
if (!match) fail('WorkBite requires Linux x64 with glibc; Alpine/musl Linux is unsupported. Nothing was downloaded.');
|
|
54
|
-
if (Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 28)) {
|
|
55
|
-
fail(`WorkBite requires glibc 2.28 or newer; this system has ${glibc}. Nothing was downloaded.`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return target;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
62
|
-
function canonicalVersion(value) { return typeof value === 'string' && SEMVER.test(value); }
|
|
63
|
-
|
|
64
|
-
// manifest.version names the sealed native artifact (asset, tag, URL, bundle, cache directory).
|
|
65
|
-
// An explicit launcherVersion lets a launcher-only npm patch reuse that artifact unchanged;
|
|
66
|
-
// without it the legacy binding (artifact version == package version) still applies.
|
|
67
|
-
function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}, launcher = packageVersion) {
|
|
68
|
-
const target = platformTarget(platform, arch, runtime);
|
|
69
|
-
const bound = Object.hasOwn(manifest, 'launcherVersion')
|
|
70
|
-
? canonicalVersion(manifest.launcherVersion) && manifest.launcherVersion === launcher
|
|
71
|
-
: manifest.version === launcher;
|
|
72
|
-
if (manifest.schema !== 'workbite.release.v1' || manifest.product !== 'WorkBite' || !canonicalVersion(manifest.version) || !bound
|
|
73
|
-
|| manifest.repository !== REPOSITORY || manifest.tag !== `v${manifest.version}`) fail('Invalid pinned WorkBite release identity');
|
|
74
|
-
const spec = manifest.platforms?.[target];
|
|
75
|
-
const asset = `workbite-${manifest.version}-${target}${platform === 'win32' ? '.zip' : '.tar.gz'}`;
|
|
76
|
-
if (!spec || spec.asset !== asset
|
|
77
|
-
|| spec.url !== `https://github.com/${manifest.repository}/releases/download/${manifest.tag}/${asset}`) fail('Invalid pinned release asset');
|
|
78
|
-
if (spec.sha256 === null || spec.size === null || spec.bundleSha256 === null) {
|
|
79
|
-
fail(`This workbite ${launcher} package has no sealed ${target} release artifact yet. Nothing was downloaded.`);
|
|
80
|
-
}
|
|
81
|
-
if (![spec.sha256, spec.bundleSha256].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))
|
|
82
|
-
|| !Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned release asset');
|
|
83
|
-
return { asset, url: spec.url, sha256: spec.sha256, size: spec.size, bundleSha256: spec.bundleSha256,
|
|
84
|
-
platform, target, version: manifest.version };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function expectedExecutables(spec, schema = BUNDLE_V1) {
|
|
88
|
-
if (!Object.hasOwn(ROSTERS, schema)) fail('bundle.json identity or executable layout mismatch');
|
|
89
|
-
const suffix = spec.platform === 'win32' ? '.exe' : '';
|
|
90
|
-
return Object.fromEntries(Object.entries(ROSTERS[schema]).map(([name, file]) => [name, file + suffix]));
|
|
91
|
-
}
|
|
92
|
-
function plainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
93
|
-
function sameKeys(value, keys) {
|
|
94
|
-
return plainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Full payload proof: pinned bundle.json bytes, then every regular file against its map, with no extras.
|
|
98
|
-
async function validateBundle(directory, spec) {
|
|
99
|
-
const info = await fs.lstat(directory);
|
|
100
|
-
if (!info.isDirectory() || info.isSymbolicLink()) fail('Installed WorkBite root is not a regular directory');
|
|
101
|
-
const root = await fs.realpath(directory);
|
|
102
|
-
const bundleBytes = await readSmall(path.join(root, BUNDLE));
|
|
103
|
-
if (sha256(bundleBytes) !== spec.bundleSha256) fail('Pinned bundle.json SHA-256 mismatch');
|
|
104
|
-
let bundle;
|
|
105
|
-
try { bundle = JSON.parse(bundleBytes.toString('utf8')); } catch { fail('bundle.json is not valid JSON'); }
|
|
106
|
-
if (!plainObject(bundle) || !Object.hasOwn(ROSTERS, bundle.schema)) fail('bundle.json identity or executable layout mismatch');
|
|
107
|
-
const executables = expectedExecutables(spec, bundle.schema);
|
|
108
|
-
// Optional reviewed Windows mobile assembly. Older v1/v2 bundles keep their exact roster.
|
|
109
|
-
// A mobile entry is accepted only at this fixed path and receives the same full hash/magic proof.
|
|
110
|
-
if (bundle.schema === BUNDLE_V2 && spec.platform === 'win32' && Object.hasOwn(bundle.executables || {}, 'workbite_mobile')) {
|
|
111
|
-
executables.workbite_mobile = 'workstack/workbite-mobile.exe';
|
|
112
|
-
}
|
|
113
|
-
if (bundle.product !== 'WorkBite'
|
|
114
|
-
|| bundle.version !== spec.version || bundle.target !== spec.target || !plainObject(bundle.components)
|
|
115
|
-
|| !sameKeys(bundle.executables, Object.keys(executables))
|
|
116
|
-
|| Object.entries(executables).some(([name, file]) => bundle.executables[name] !== file)
|
|
117
|
-
|| !plainObject(bundle.files)) fail('bundle.json identity or executable layout mismatch');
|
|
118
|
-
const expected = new Map();
|
|
119
|
-
for (const [name, digest] of Object.entries(bundle.files)) {
|
|
120
|
-
if (memberPath(name) !== name || name === BUNDLE || !TOP_DIRECTORIES.includes(name.split('/')[0])
|
|
121
|
-
|| !name.includes('/') || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) fail('Invalid bundle file entry');
|
|
122
|
-
const key = spec.platform === 'win32' ? name.toLowerCase() : name;
|
|
123
|
-
if (expected.has(key)) fail('Duplicate bundle file entry');
|
|
124
|
-
expected.set(key, { name, digest });
|
|
125
|
-
}
|
|
126
|
-
if (!expected.size || expected.size > LIMITS.members) fail('Invalid bundle file map');
|
|
127
|
-
for (const file of Object.values(executables)) if (!Object.hasOwn(bundle.files, file)) fail('Bundle executable is not in its file map');
|
|
128
|
-
let total = 0, entries = 0, seen = 0;
|
|
129
|
-
async function visit(current) {
|
|
130
|
-
for (const name of await fs.readdir(current)) {
|
|
131
|
-
if (++entries > LIMITS.members * 2) fail('Installed WorkBite has too many entries');
|
|
132
|
-
const target = path.join(current, name);
|
|
133
|
-
const relative = path.relative(root, target).split(path.sep).join('/');
|
|
134
|
-
memberPath(relative);
|
|
135
|
-
const entry = await fs.lstat(target);
|
|
136
|
-
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) fail('Installed WorkBite contains a link or special file');
|
|
137
|
-
if (entry.isDirectory()) {
|
|
138
|
-
if (current === root && !TOP_DIRECTORIES.includes(name)) fail('Installed WorkBite has an unexpected top-level directory');
|
|
139
|
-
await visit(target); continue;
|
|
140
|
-
}
|
|
141
|
-
if (relative === BUNDLE) continue;
|
|
142
|
-
const record = expected.get(spec.platform === 'win32' ? relative.toLowerCase() : relative);
|
|
143
|
-
if (!record || record.name !== relative) fail(`Installed file is not in bundle.json: ${relative}`);
|
|
144
|
-
total += entry.size;
|
|
145
|
-
if (total > LIMITS.expanded) fail('Installed WorkBite exceeds expansion limit');
|
|
146
|
-
if (await hashFile(target) !== record.digest) fail(`Installed file SHA-256 mismatch: ${relative}`);
|
|
147
|
-
seen++;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
await visit(root);
|
|
151
|
-
if (seen !== expected.size) fail('Installed files disagree with bundle.json');
|
|
152
|
-
const paths = {};
|
|
153
|
-
for (const [name, file] of Object.entries(executables)) {
|
|
154
|
-
const executable = path.join(root, ...file.split('/'));
|
|
155
|
-
const handle = await fs.open(executable, 'r');
|
|
156
|
-
try {
|
|
157
|
-
const magic = Buffer.alloc(4);
|
|
158
|
-
if ((await handle.read(magic, 0, 4, 0)).bytesRead !== 4
|
|
159
|
-
|| !(spec.platform === 'win32' ? magic.subarray(0, 2).equals(Buffer.from('MZ')) : magic.equals(Buffer.from([0x7f, 69, 76, 70])))) {
|
|
160
|
-
fail(`Invalid native executable identity: ${file}`);
|
|
161
|
-
}
|
|
162
|
-
} finally { await handle.close(); }
|
|
163
|
-
if (spec.platform === 'linux' && process.platform !== 'win32' && !((await fs.stat(executable)).mode & 0o111)) {
|
|
164
|
-
fail(`Native executable has no execute permission: ${file}`);
|
|
165
|
-
}
|
|
166
|
-
paths[name] = executable;
|
|
167
|
-
}
|
|
168
|
-
return { home: root, executables: paths, bundle };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function installName(spec) { return `${spec.version}-${spec.target}-${spec.sha256.slice(0, 16)}`; }
|
|
172
|
-
|
|
173
|
-
// Windows cannot start bundle executables (or load their files) past MAX_PATH.
|
|
174
|
-
const WINDOWS_MAX_PATH = 259;
|
|
175
|
-
function checkPathBudget(target, bundle, spec) {
|
|
176
|
-
if (spec.platform !== 'win32') return;
|
|
177
|
-
const longest = Object.keys(bundle.files).reduce((best, name) => (name.length > best.length ? name : best), '');
|
|
178
|
-
if (path.join(target, longest).length > WINDOWS_MAX_PATH) {
|
|
179
|
-
fail(`WorkBite app folder path is too long for Windows (${path.join(target, longest).length} > ${WINDOWS_MAX_PATH} characters); set WORKBITE_HOME to a shorter absolute folder.`);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// A version directory is published once by rename and never rewritten; reuse revalidates it fully.
|
|
184
|
-
async function installVersion(spec, { apps, downloadImpl = download, extractImpl = extractArchive,
|
|
185
|
-
checkOnly = false, progress = () => {} } = {}) {
|
|
186
|
-
apps = path.resolve(apps);
|
|
187
|
-
const target = path.join(apps, installName(spec));
|
|
188
|
-
if (await exists(target)) {
|
|
189
|
-
progress('validate');
|
|
190
|
-
const installed = await validateBundle(target, spec);
|
|
191
|
-
checkPathBudget(target, installed.bundle, spec);
|
|
192
|
-
progress('ready');
|
|
193
|
-
return installed;
|
|
194
|
-
}
|
|
195
|
-
if (checkOnly) fail('WorkBite is not installed for this package version; run workbite install.');
|
|
196
|
-
await safeDirectory(apps);
|
|
197
|
-
const staging = await fs.mkdtemp(path.join(apps, '.staging-'));
|
|
198
|
-
let cleanup = true;
|
|
199
|
-
try {
|
|
200
|
-
const archive = path.join(staging, spec.asset), extraction = path.join(staging, 'payload');
|
|
201
|
-
await fs.mkdir(extraction, { mode: 0o700 });
|
|
202
|
-
progress('download');
|
|
203
|
-
await downloadImpl(spec, archive);
|
|
204
|
-
progress('archive');
|
|
205
|
-
if ((await fs.stat(archive)).size !== spec.size || await hashFile(archive, LIMITS.download) !== spec.sha256) fail('Pinned archive SHA-256 or size mismatch');
|
|
206
|
-
progress('extract');
|
|
207
|
-
await extractImpl(archive, extraction, spec);
|
|
208
|
-
progress('validate');
|
|
209
|
-
checkPathBudget(target, (await validateBundle(extraction, spec)).bundle, spec);
|
|
210
|
-
progress('publish');
|
|
211
|
-
try { await fs.rename(extraction, target); }
|
|
212
|
-
catch (error) {
|
|
213
|
-
// A concurrent installer may have published the same immutable version first.
|
|
214
|
-
if (!await exists(target)) throw error;
|
|
215
|
-
}
|
|
216
|
-
progress('validate-published');
|
|
217
|
-
const installed = await validateBundle(target, spec);
|
|
218
|
-
progress('ready');
|
|
219
|
-
return installed;
|
|
220
|
-
} catch (error) {
|
|
221
|
-
if (error.extractionMayBeRunning) cleanup = false;
|
|
222
|
-
throw error;
|
|
223
|
-
} finally {
|
|
224
|
-
// Only this invocation's mkdtemp staging directory is ever removed.
|
|
225
|
-
if (!inside(apps, staging) || path.dirname(staging) !== apps) fail('Unsafe staging cleanup path');
|
|
226
|
-
if (cleanup) await fs.rm(staging, { recursive: true, force: true });
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Keep progress on stderr through the existing reporter; stdout remains the
|
|
231
|
-
// workbite.install.v1 document. Every reuse still performs full hash validation.
|
|
232
|
-
async function ensureInstalled(spec, options = {}) {
|
|
233
|
-
const apps = path.resolve(options.apps);
|
|
234
|
-
const target = path.join(apps, installName(spec));
|
|
235
|
-
const started = performance.now();
|
|
236
|
-
let verifying, verificationMs = 0;
|
|
237
|
-
const progress = stage => {
|
|
238
|
-
const now = performance.now();
|
|
239
|
-
if (verifying !== undefined) { verificationMs += now - verifying; verifying = undefined; }
|
|
240
|
-
if (stage === 'validate' || stage === 'validate-published') verifying = now;
|
|
241
|
-
(options.progress || (() => {}))(stage, { elapsedMs: Math.round(now - started),
|
|
242
|
-
verificationMs: Math.round(verificationMs), home: target, apps });
|
|
243
|
-
};
|
|
244
|
-
try {
|
|
245
|
-
return await installVersion(spec, { ...options, apps, progress });
|
|
246
|
-
} catch (error) {
|
|
247
|
-
const failure = new Error(`${diagnosticText(error.message, 8192)} Installation path: ${diagnosticText(target)} (apps: ${diagnosticText(apps)}); elapsed: ${Math.round(performance.now() - started)} ms.`);
|
|
248
|
-
for (const key of ['code', 'exitCode', 'signal']) {
|
|
249
|
-
if (typeof error[key] === 'string') failure[key] = diagnosticText(error[key], 64);
|
|
250
|
-
else if (error[key] === null || typeof error[key] === 'number') failure[key] = error[key];
|
|
251
|
-
}
|
|
252
|
-
throw failure;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function stageReporter(stderr) {
|
|
257
|
-
const messages = {
|
|
258
|
-
download: 'Downloading pinned WorkBite release...',
|
|
259
|
-
archive: 'Verifying archive...',
|
|
260
|
-
extract: 'Extracting...',
|
|
261
|
-
validate: 'Validating installed files...',
|
|
262
|
-
publish: 'Publishing version directory...',
|
|
263
|
-
'validate-published': 'Validating published version...',
|
|
264
|
-
ready: 'Verified WorkBite release is ready.',
|
|
265
|
-
};
|
|
266
|
-
return (stage, details) => {
|
|
267
|
-
const timing = stage === 'ready' && details
|
|
268
|
-
? ` Elapsed: ${details.elapsedMs} ms; file verification: ${details.verificationMs} ms; home: ${diagnosticText(details.home)}.` : '';
|
|
269
|
-
stderr.write(`WorkBite: ${messages[stage]}${timing}\n`);
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
module.exports = { REPOSITORY, TARGETS, EXECUTABLES, BUNDLE_V1, BUNDLE_V2, ROSTERS, locations, platformTarget, releaseSpec, expectedExecutables,
|
|
274
|
-
validateBundle, ensureInstalled, installName, stageReporter, packageVersion };
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const { performance } = require('node:perf_hooks');
|
|
6
|
+
const { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists, safeDirectory, sha256 } = require('./common');
|
|
7
|
+
const { download } = require('./download');
|
|
8
|
+
const { extractArchive, diagnosticText, TOP_DIRECTORIES, BUNDLE } = require('./archive');
|
|
9
|
+
const packageVersion = require('../package.json').version;
|
|
10
|
+
|
|
11
|
+
const REPOSITORY = 'Shinick-Han/WorkBite-releases';
|
|
12
|
+
const TARGETS = Object.freeze({ 'win32-x64': 'windows-x64', 'linux-x64': 'linux-x64' });
|
|
13
|
+
const EXECUTABLES = Object.freeze({
|
|
14
|
+
workstack_engine: 'workstack/workstack-native-engine',
|
|
15
|
+
workstack_client: 'workstack/workstack-native-client',
|
|
16
|
+
workstack_tui: 'workstack/workstack-tui',
|
|
17
|
+
stackbite: 'stackbite/stackbite',
|
|
18
|
+
});
|
|
19
|
+
// Exact executable roster per bundle schema: v1 keeps the four legacy entries; v2 adds only the
|
|
20
|
+
// same-window execution UI (proprietary assembly binary workstack-native-tui). Any other schema is refused.
|
|
21
|
+
const BUNDLE_V1 = 'workbite.bundle.v1';
|
|
22
|
+
const BUNDLE_V2 = 'workbite.bundle.v2';
|
|
23
|
+
const ROSTERS = Object.freeze({
|
|
24
|
+
[BUNDLE_V1]: EXECUTABLES,
|
|
25
|
+
[BUNDLE_V2]: Object.freeze({ ...EXECUTABLES, workbite_tui: 'workstack/workstack-native-tui' }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function absoluteOverride(environment, name) {
|
|
29
|
+
const value = environment[name];
|
|
30
|
+
if (value === undefined || value === '') return undefined;
|
|
31
|
+
if (!path.isAbsolute(value) || /[\x00-\x1f]/.test(value)) fail(`${name} must be an absolute path`);
|
|
32
|
+
return path.resolve(value);
|
|
33
|
+
}
|
|
34
|
+
// Per-user locations owned by WorkBite only; existing Work Stack/StackBite locations are never used.
|
|
35
|
+
function locations(environment = process.env, platform = process.platform, home = os.homedir()) {
|
|
36
|
+
const windows = platform === 'win32';
|
|
37
|
+
const local = windows ? (environment.LOCALAPPDATA || path.join(home, 'AppData', 'Local')) : undefined;
|
|
38
|
+
const data = absoluteOverride(environment, 'WORKBITE_HOME') || (windows ? path.resolve(local, 'WorkBite')
|
|
39
|
+
: path.resolve(environment.XDG_DATA_HOME || path.join(environment.HOME || home, '.local', 'share'), 'workbite'));
|
|
40
|
+
const state = absoluteOverride(environment, 'WORKBITE_STATE_DIR') || (windows ? path.resolve(local, 'WorkBite', 'state')
|
|
41
|
+
: path.resolve(environment.XDG_STATE_HOME || path.join(environment.HOME || home, '.local', 'state'), 'workbite'));
|
|
42
|
+
return { apps: path.join(data, 'app'), state };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function platformTarget(platform = process.platform, arch = process.arch, runtime = {}) {
|
|
46
|
+
const key = `${platform}-${arch}`;
|
|
47
|
+
const target = TARGETS[key];
|
|
48
|
+
if (!target) fail(`WorkBite has no native release for ${key}; supported platforms are Windows x64 and Linux x64 (glibc 2.28+). Nothing was downloaded.`);
|
|
49
|
+
if (platform === 'linux' && (process.platform === 'linux' || Object.hasOwn(runtime, 'glibcVersionRuntime'))) {
|
|
50
|
+
const glibc = Object.hasOwn(runtime, 'glibcVersionRuntime')
|
|
51
|
+
? runtime.glibcVersionRuntime : process.report.getReport().header.glibcVersionRuntime;
|
|
52
|
+
const match = typeof glibc === 'string' ? /^(\d+)\.(\d+)/.exec(glibc) : null;
|
|
53
|
+
if (!match) fail('WorkBite requires Linux x64 with glibc; Alpine/musl Linux is unsupported. Nothing was downloaded.');
|
|
54
|
+
if (Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 28)) {
|
|
55
|
+
fail(`WorkBite requires glibc 2.28 or newer; this system has ${glibc}. Nothing was downloaded.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return target;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
62
|
+
function canonicalVersion(value) { return typeof value === 'string' && SEMVER.test(value); }
|
|
63
|
+
|
|
64
|
+
// manifest.version names the sealed native artifact (asset, tag, URL, bundle, cache directory).
|
|
65
|
+
// An explicit launcherVersion lets a launcher-only npm patch reuse that artifact unchanged;
|
|
66
|
+
// without it the legacy binding (artifact version == package version) still applies.
|
|
67
|
+
function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}, launcher = packageVersion) {
|
|
68
|
+
const target = platformTarget(platform, arch, runtime);
|
|
69
|
+
const bound = Object.hasOwn(manifest, 'launcherVersion')
|
|
70
|
+
? canonicalVersion(manifest.launcherVersion) && manifest.launcherVersion === launcher
|
|
71
|
+
: manifest.version === launcher;
|
|
72
|
+
if (manifest.schema !== 'workbite.release.v1' || manifest.product !== 'WorkBite' || !canonicalVersion(manifest.version) || !bound
|
|
73
|
+
|| manifest.repository !== REPOSITORY || manifest.tag !== `v${manifest.version}`) fail('Invalid pinned WorkBite release identity');
|
|
74
|
+
const spec = manifest.platforms?.[target];
|
|
75
|
+
const asset = `workbite-${manifest.version}-${target}${platform === 'win32' ? '.zip' : '.tar.gz'}`;
|
|
76
|
+
if (!spec || spec.asset !== asset
|
|
77
|
+
|| spec.url !== `https://github.com/${manifest.repository}/releases/download/${manifest.tag}/${asset}`) fail('Invalid pinned release asset');
|
|
78
|
+
if (spec.sha256 === null || spec.size === null || spec.bundleSha256 === null) {
|
|
79
|
+
fail(`This workbite ${launcher} package has no sealed ${target} release artifact yet. Nothing was downloaded.`);
|
|
80
|
+
}
|
|
81
|
+
if (![spec.sha256, spec.bundleSha256].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))
|
|
82
|
+
|| !Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned release asset');
|
|
83
|
+
return { asset, url: spec.url, sha256: spec.sha256, size: spec.size, bundleSha256: spec.bundleSha256,
|
|
84
|
+
platform, target, version: manifest.version };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function expectedExecutables(spec, schema = BUNDLE_V1) {
|
|
88
|
+
if (!Object.hasOwn(ROSTERS, schema)) fail('bundle.json identity or executable layout mismatch');
|
|
89
|
+
const suffix = spec.platform === 'win32' ? '.exe' : '';
|
|
90
|
+
return Object.fromEntries(Object.entries(ROSTERS[schema]).map(([name, file]) => [name, file + suffix]));
|
|
91
|
+
}
|
|
92
|
+
function plainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
93
|
+
function sameKeys(value, keys) {
|
|
94
|
+
return plainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Full payload proof: pinned bundle.json bytes, then every regular file against its map, with no extras.
|
|
98
|
+
async function validateBundle(directory, spec) {
|
|
99
|
+
const info = await fs.lstat(directory);
|
|
100
|
+
if (!info.isDirectory() || info.isSymbolicLink()) fail('Installed WorkBite root is not a regular directory');
|
|
101
|
+
const root = await fs.realpath(directory);
|
|
102
|
+
const bundleBytes = await readSmall(path.join(root, BUNDLE));
|
|
103
|
+
if (sha256(bundleBytes) !== spec.bundleSha256) fail('Pinned bundle.json SHA-256 mismatch');
|
|
104
|
+
let bundle;
|
|
105
|
+
try { bundle = JSON.parse(bundleBytes.toString('utf8')); } catch { fail('bundle.json is not valid JSON'); }
|
|
106
|
+
if (!plainObject(bundle) || !Object.hasOwn(ROSTERS, bundle.schema)) fail('bundle.json identity or executable layout mismatch');
|
|
107
|
+
const executables = expectedExecutables(spec, bundle.schema);
|
|
108
|
+
// Optional reviewed Windows mobile assembly. Older v1/v2 bundles keep their exact roster.
|
|
109
|
+
// A mobile entry is accepted only at this fixed path and receives the same full hash/magic proof.
|
|
110
|
+
if (bundle.schema === BUNDLE_V2 && spec.platform === 'win32' && Object.hasOwn(bundle.executables || {}, 'workbite_mobile')) {
|
|
111
|
+
executables.workbite_mobile = 'workstack/workbite-mobile.exe';
|
|
112
|
+
}
|
|
113
|
+
if (bundle.product !== 'WorkBite'
|
|
114
|
+
|| bundle.version !== spec.version || bundle.target !== spec.target || !plainObject(bundle.components)
|
|
115
|
+
|| !sameKeys(bundle.executables, Object.keys(executables))
|
|
116
|
+
|| Object.entries(executables).some(([name, file]) => bundle.executables[name] !== file)
|
|
117
|
+
|| !plainObject(bundle.files)) fail('bundle.json identity or executable layout mismatch');
|
|
118
|
+
const expected = new Map();
|
|
119
|
+
for (const [name, digest] of Object.entries(bundle.files)) {
|
|
120
|
+
if (memberPath(name) !== name || name === BUNDLE || !TOP_DIRECTORIES.includes(name.split('/')[0])
|
|
121
|
+
|| !name.includes('/') || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) fail('Invalid bundle file entry');
|
|
122
|
+
const key = spec.platform === 'win32' ? name.toLowerCase() : name;
|
|
123
|
+
if (expected.has(key)) fail('Duplicate bundle file entry');
|
|
124
|
+
expected.set(key, { name, digest });
|
|
125
|
+
}
|
|
126
|
+
if (!expected.size || expected.size > LIMITS.members) fail('Invalid bundle file map');
|
|
127
|
+
for (const file of Object.values(executables)) if (!Object.hasOwn(bundle.files, file)) fail('Bundle executable is not in its file map');
|
|
128
|
+
let total = 0, entries = 0, seen = 0;
|
|
129
|
+
async function visit(current) {
|
|
130
|
+
for (const name of await fs.readdir(current)) {
|
|
131
|
+
if (++entries > LIMITS.members * 2) fail('Installed WorkBite has too many entries');
|
|
132
|
+
const target = path.join(current, name);
|
|
133
|
+
const relative = path.relative(root, target).split(path.sep).join('/');
|
|
134
|
+
memberPath(relative);
|
|
135
|
+
const entry = await fs.lstat(target);
|
|
136
|
+
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) fail('Installed WorkBite contains a link or special file');
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
if (current === root && !TOP_DIRECTORIES.includes(name)) fail('Installed WorkBite has an unexpected top-level directory');
|
|
139
|
+
await visit(target); continue;
|
|
140
|
+
}
|
|
141
|
+
if (relative === BUNDLE) continue;
|
|
142
|
+
const record = expected.get(spec.platform === 'win32' ? relative.toLowerCase() : relative);
|
|
143
|
+
if (!record || record.name !== relative) fail(`Installed file is not in bundle.json: ${relative}`);
|
|
144
|
+
total += entry.size;
|
|
145
|
+
if (total > LIMITS.expanded) fail('Installed WorkBite exceeds expansion limit');
|
|
146
|
+
if (await hashFile(target) !== record.digest) fail(`Installed file SHA-256 mismatch: ${relative}`);
|
|
147
|
+
seen++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await visit(root);
|
|
151
|
+
if (seen !== expected.size) fail('Installed files disagree with bundle.json');
|
|
152
|
+
const paths = {};
|
|
153
|
+
for (const [name, file] of Object.entries(executables)) {
|
|
154
|
+
const executable = path.join(root, ...file.split('/'));
|
|
155
|
+
const handle = await fs.open(executable, 'r');
|
|
156
|
+
try {
|
|
157
|
+
const magic = Buffer.alloc(4);
|
|
158
|
+
if ((await handle.read(magic, 0, 4, 0)).bytesRead !== 4
|
|
159
|
+
|| !(spec.platform === 'win32' ? magic.subarray(0, 2).equals(Buffer.from('MZ')) : magic.equals(Buffer.from([0x7f, 69, 76, 70])))) {
|
|
160
|
+
fail(`Invalid native executable identity: ${file}`);
|
|
161
|
+
}
|
|
162
|
+
} finally { await handle.close(); }
|
|
163
|
+
if (spec.platform === 'linux' && process.platform !== 'win32' && !((await fs.stat(executable)).mode & 0o111)) {
|
|
164
|
+
fail(`Native executable has no execute permission: ${file}`);
|
|
165
|
+
}
|
|
166
|
+
paths[name] = executable;
|
|
167
|
+
}
|
|
168
|
+
return { home: root, executables: paths, bundle };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function installName(spec) { return `${spec.version}-${spec.target}-${spec.sha256.slice(0, 16)}`; }
|
|
172
|
+
|
|
173
|
+
// Windows cannot start bundle executables (or load their files) past MAX_PATH.
|
|
174
|
+
const WINDOWS_MAX_PATH = 259;
|
|
175
|
+
function checkPathBudget(target, bundle, spec) {
|
|
176
|
+
if (spec.platform !== 'win32') return;
|
|
177
|
+
const longest = Object.keys(bundle.files).reduce((best, name) => (name.length > best.length ? name : best), '');
|
|
178
|
+
if (path.join(target, longest).length > WINDOWS_MAX_PATH) {
|
|
179
|
+
fail(`WorkBite app folder path is too long for Windows (${path.join(target, longest).length} > ${WINDOWS_MAX_PATH} characters); set WORKBITE_HOME to a shorter absolute folder.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A version directory is published once by rename and never rewritten; reuse revalidates it fully.
|
|
184
|
+
async function installVersion(spec, { apps, downloadImpl = download, extractImpl = extractArchive,
|
|
185
|
+
checkOnly = false, progress = () => {} } = {}) {
|
|
186
|
+
apps = path.resolve(apps);
|
|
187
|
+
const target = path.join(apps, installName(spec));
|
|
188
|
+
if (await exists(target)) {
|
|
189
|
+
progress('validate');
|
|
190
|
+
const installed = await validateBundle(target, spec);
|
|
191
|
+
checkPathBudget(target, installed.bundle, spec);
|
|
192
|
+
progress('ready');
|
|
193
|
+
return installed;
|
|
194
|
+
}
|
|
195
|
+
if (checkOnly) fail('WorkBite is not installed for this package version; run workbite install.');
|
|
196
|
+
await safeDirectory(apps);
|
|
197
|
+
const staging = await fs.mkdtemp(path.join(apps, '.staging-'));
|
|
198
|
+
let cleanup = true;
|
|
199
|
+
try {
|
|
200
|
+
const archive = path.join(staging, spec.asset), extraction = path.join(staging, 'payload');
|
|
201
|
+
await fs.mkdir(extraction, { mode: 0o700 });
|
|
202
|
+
progress('download');
|
|
203
|
+
await downloadImpl(spec, archive);
|
|
204
|
+
progress('archive');
|
|
205
|
+
if ((await fs.stat(archive)).size !== spec.size || await hashFile(archive, LIMITS.download) !== spec.sha256) fail('Pinned archive SHA-256 or size mismatch');
|
|
206
|
+
progress('extract');
|
|
207
|
+
await extractImpl(archive, extraction, spec);
|
|
208
|
+
progress('validate');
|
|
209
|
+
checkPathBudget(target, (await validateBundle(extraction, spec)).bundle, spec);
|
|
210
|
+
progress('publish');
|
|
211
|
+
try { await fs.rename(extraction, target); }
|
|
212
|
+
catch (error) {
|
|
213
|
+
// A concurrent installer may have published the same immutable version first.
|
|
214
|
+
if (!await exists(target)) throw error;
|
|
215
|
+
}
|
|
216
|
+
progress('validate-published');
|
|
217
|
+
const installed = await validateBundle(target, spec);
|
|
218
|
+
progress('ready');
|
|
219
|
+
return installed;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.extractionMayBeRunning) cleanup = false;
|
|
222
|
+
throw error;
|
|
223
|
+
} finally {
|
|
224
|
+
// Only this invocation's mkdtemp staging directory is ever removed.
|
|
225
|
+
if (!inside(apps, staging) || path.dirname(staging) !== apps) fail('Unsafe staging cleanup path');
|
|
226
|
+
if (cleanup) await fs.rm(staging, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Keep progress on stderr through the existing reporter; stdout remains the
|
|
231
|
+
// workbite.install.v1 document. Every reuse still performs full hash validation.
|
|
232
|
+
async function ensureInstalled(spec, options = {}) {
|
|
233
|
+
const apps = path.resolve(options.apps);
|
|
234
|
+
const target = path.join(apps, installName(spec));
|
|
235
|
+
const started = performance.now();
|
|
236
|
+
let verifying, verificationMs = 0;
|
|
237
|
+
const progress = stage => {
|
|
238
|
+
const now = performance.now();
|
|
239
|
+
if (verifying !== undefined) { verificationMs += now - verifying; verifying = undefined; }
|
|
240
|
+
if (stage === 'validate' || stage === 'validate-published') verifying = now;
|
|
241
|
+
(options.progress || (() => {}))(stage, { elapsedMs: Math.round(now - started),
|
|
242
|
+
verificationMs: Math.round(verificationMs), home: target, apps });
|
|
243
|
+
};
|
|
244
|
+
try {
|
|
245
|
+
return await installVersion(spec, { ...options, apps, progress });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const failure = new Error(`${diagnosticText(error.message, 8192)} Installation path: ${diagnosticText(target)} (apps: ${diagnosticText(apps)}); elapsed: ${Math.round(performance.now() - started)} ms.`);
|
|
248
|
+
for (const key of ['code', 'exitCode', 'signal']) {
|
|
249
|
+
if (typeof error[key] === 'string') failure[key] = diagnosticText(error[key], 64);
|
|
250
|
+
else if (error[key] === null || typeof error[key] === 'number') failure[key] = error[key];
|
|
251
|
+
}
|
|
252
|
+
throw failure;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function stageReporter(stderr) {
|
|
257
|
+
const messages = {
|
|
258
|
+
download: 'Downloading pinned WorkBite release...',
|
|
259
|
+
archive: 'Verifying archive...',
|
|
260
|
+
extract: 'Extracting...',
|
|
261
|
+
validate: 'Validating installed files...',
|
|
262
|
+
publish: 'Publishing version directory...',
|
|
263
|
+
'validate-published': 'Validating published version...',
|
|
264
|
+
ready: 'Verified WorkBite release is ready.',
|
|
265
|
+
};
|
|
266
|
+
return (stage, details) => {
|
|
267
|
+
const timing = stage === 'ready' && details
|
|
268
|
+
? ` Elapsed: ${details.elapsedMs} ms; file verification: ${details.verificationMs} ms; home: ${diagnosticText(details.home)}.` : '';
|
|
269
|
+
stderr.write(`WorkBite: ${messages[stage]}${timing}\n`);
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { REPOSITORY, TARGETS, EXECUTABLES, BUNDLE_V1, BUNDLE_V2, ROSTERS, locations, platformTarget, releaseSpec, expectedExecutables,
|
|
274
|
+
validateBundle, ensureInstalled, installName, stageReporter, packageVersion };
|