stackbite 0.5.94 → 0.5.96
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/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
const { main } = require('../lib/bootstrap');
|
|
4
|
-
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
main(process.argv.slice(2), { packageRoot: path.resolve(__dirname, '../../..') }).then(({ code, signal }) => {
|
|
5
6
|
if (signal && process.platform !== 'win32') {
|
|
6
7
|
process.kill(process.pid, signal);
|
|
7
8
|
} else {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const fs = require('node:fs/promises');
|
|
3
3
|
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
4
5
|
const { spawn } = require('node:child_process');
|
|
5
6
|
const { createHash } = require('node:crypto');
|
|
6
|
-
const { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists
|
|
7
|
+
const { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists } = require('./common');
|
|
7
8
|
const { download } = require('./download');
|
|
8
9
|
const { extractArchive } = require('./archive');
|
|
9
10
|
const { cacheDirectory, existingCacheDirectory } = require('./cache');
|
|
@@ -157,8 +158,9 @@ async function nativeExecutable(root, spec, name = 'stackbite') {
|
|
|
157
158
|
if (spec.platform !== 'win32' && process.platform !== 'win32' && !(image.mode & 0o111)) fail('Native release executable has no execute permission');
|
|
158
159
|
return executable;
|
|
159
160
|
}
|
|
160
|
-
// The release pins the whole payload before this mapping.
|
|
161
|
-
//
|
|
161
|
+
// The release pins the whole payload before this mapping. Windows opens the
|
|
162
|
+
// frozen engine, which selects the Rust TUI/owner; POSIX opens the native entry.
|
|
163
|
+
// A bundled native entry is validated on every platform before either choice.
|
|
162
164
|
async function publicExecutable(root, spec, hashes) {
|
|
163
165
|
const engine = await nativeExecutable(root, spec);
|
|
164
166
|
const name = 'stackbite-launcher' + (spec.platform === 'win32' ? '.exe' : '');
|
|
@@ -177,7 +179,8 @@ async function publicExecutable(root, spec, hashes) {
|
|
|
177
179
|
|| !info || Object.keys(info).sort().join(',') !== 'binary,commit,product,protocol,target,version'
|
|
178
180
|
|| info.product !== 'Stackbite' || info.binary !== 'stackbite-launcher' || info.version !== spec.version
|
|
179
181
|
|| info.commit !== spec.commit || info.target !== target || info.protocol !== 1) fail('Native entry package identity or pin is invalid');
|
|
180
|
-
|
|
182
|
+
const launcher = await nativeExecutable(root, spec, 'stackbite-launcher');
|
|
183
|
+
return spec.platform === 'win32' ? engine : launcher;
|
|
181
184
|
}
|
|
182
185
|
async function validatePayload(directory, spec, { includePhone = true } = {}) {
|
|
183
186
|
const root = await payloadRoot(directory);
|
|
@@ -189,52 +192,322 @@ async function validatePayload(directory, spec, { includePhone = true } = {}) {
|
|
|
189
192
|
await checkProductIdentity(root, spec, metadata);
|
|
190
193
|
return publicExecutable(root, spec, hashes);
|
|
191
194
|
}
|
|
195
|
+
|
|
196
|
+
function installationRoot(environment, platform) {
|
|
197
|
+
const home = environment.USERPROFILE || environment.HOME || os.homedir();
|
|
198
|
+
// Python's locator/control root is distinct from npm's cache root.
|
|
199
|
+
return platform === 'win32'
|
|
200
|
+
? path.resolve(environment.LOCALAPPDATA || home, 'Stackbite')
|
|
201
|
+
: path.resolve(environment.XDG_DATA_HOME || path.join(environment.HOME || home, '.local', 'share'), 'stackbite');
|
|
202
|
+
}
|
|
203
|
+
// Before a runtime is available, read the SAME journal without creating any
|
|
204
|
+
// product directory. The verified bundled engine repeats this under OS locks
|
|
205
|
+
// and owns cache/locator publication. This read is not a JavaScript flock.
|
|
206
|
+
async function admitPublication(environment, platform) {
|
|
207
|
+
const root = installationRoot(environment, platform);
|
|
208
|
+
if (!await existingCacheDirectory(root)) return;
|
|
209
|
+
const journal = path.join(root, '.stackbite-uninstall.json');
|
|
210
|
+
if (!await exists(journal)) return;
|
|
211
|
+
const record = JSON.parse((await readSmall(journal, 8 * 1024 * 1024)).toString('utf8').replace(/^\uFEFF/, ''));
|
|
212
|
+
if (record.schema !== 'stackbite.uninstall.v1' || !['prepared', 'stopped', 'deferred', 'complete'].includes(record.phase)) {
|
|
213
|
+
fail('Stackbite removal is in progress');
|
|
214
|
+
}
|
|
215
|
+
let current = null;
|
|
216
|
+
for (const name of ['default-install.json', 'install.json']) {
|
|
217
|
+
const locator = path.join(root, name);
|
|
218
|
+
if (!await exists(locator)) continue;
|
|
219
|
+
const value = JSON.parse((await readSmall(locator, 16384)).toString('utf8').replace(/^\uFEFF/, ''));
|
|
220
|
+
if (!value || Array.isArray(value) || typeof value !== 'object'
|
|
221
|
+
|| (value.installation_id != null && !/^[a-f0-9]{32}$/.test(value.installation_id))) fail('Invalid Stackbite installation locator');
|
|
222
|
+
current = value.installation_id;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
const saved = record.install_identity;
|
|
226
|
+
if (saved != null && (saved.root !== root || !/^[a-f0-9]{32}$/.test(saved.incarnation))) fail('Invalid removal installation identity');
|
|
227
|
+
if (current && (!saved || current !== saved.incarnation)) return;
|
|
228
|
+
fail('Stackbite removal is in progress');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function baselinePin(file) {
|
|
232
|
+
await existingCacheDirectory(path.dirname(file));
|
|
233
|
+
let info;
|
|
234
|
+
try { info = await fs.lstat(file, { bigint: true }); }
|
|
235
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
236
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n) fail('Invalid installation baseline file');
|
|
237
|
+
return { device: info.dev.toString(), inode: info.ino.toString() };
|
|
238
|
+
}
|
|
239
|
+
async function captureBaseline(environment, platform, cachedHome) {
|
|
240
|
+
const root = installationRoot(environment, platform);
|
|
241
|
+
for (const name of ['default-install.json', 'install.json']) {
|
|
242
|
+
const file = path.join(root, name);
|
|
243
|
+
if (!await exists(file)) continue;
|
|
244
|
+
const value = JSON.parse((await readSmall(file, 16384)).toString('utf8').replace(/^\uFEFF/, ''));
|
|
245
|
+
if (value.installation_id != null) {
|
|
246
|
+
if (!/^[a-f0-9]{32}$/.test(value.installation_id)) fail('Invalid installation incarnation');
|
|
247
|
+
return { root, incarnation: value.installation_id };
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
const old = path.join(path.dirname(root), platform === 'win32' ? 'Helm' : 'helm');
|
|
252
|
+
const paths = [root, old].flatMap(base => ['default-install.json', 'install.json'].map(name => path.join(base, name)));
|
|
253
|
+
const home = platform === 'win32' ? environment.USERPROFILE || os.homedir() : environment.HOME || os.homedir();
|
|
254
|
+
paths.push(...(platform === 'win32' ? ['stackbite.cmd', 'stackbite-portable.cmd', 'stackbite.exe'] : ['stackbite'])
|
|
255
|
+
.map(name => path.join(home, '.local', 'bin', name)));
|
|
256
|
+
const homes = new Set([cachedHome]);
|
|
257
|
+
for (const file of paths.slice(0, 4)) {
|
|
258
|
+
if (!await exists(file)) continue;
|
|
259
|
+
const value = JSON.parse((await readSmall(file, 16384)).toString('utf8').replace(/^\uFEFF/, ''));
|
|
260
|
+
if (typeof value.home === 'string' && path.isAbsolute(value.home)) homes.add(value.home);
|
|
261
|
+
}
|
|
262
|
+
for (const selected of homes) {
|
|
263
|
+
for (const name of ['portable.json', platform === 'win32' ? 'stackbite.exe' : 'stackbite', 'stackbite.py']) {
|
|
264
|
+
const file = path.join(selected, name);
|
|
265
|
+
if (await baselinePin(file) !== null) paths.push(file);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const legacy = [];
|
|
269
|
+
for (const file of new Set(paths)) legacy.push({ path: file, identity: await baselinePin(file) });
|
|
270
|
+
return legacy.some(pin => pin.identity !== null) ? { root, incarnation: null, legacy } : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function assertBaseline(environment, platform) {
|
|
274
|
+
const raw = environment.STACKBITE_INSTALL_BASELINE;
|
|
275
|
+
if (raw === undefined) return;
|
|
276
|
+
if (Buffer.byteLength(raw) > 16384) fail('Invalid installation baseline');
|
|
277
|
+
const expected = JSON.parse(raw), root = installationRoot(environment, platform);
|
|
278
|
+
if (expected.preparation !== undefined) {
|
|
279
|
+
const pin = expected.preparation, file = path.join(root, '.stackbite-update', 'update.lock');
|
|
280
|
+
if (Object.keys(expected).sort().join(',') !== 'preparation,root' || expected.root !== root
|
|
281
|
+
|| !pin || Object.keys(pin).sort().join(',') !== 'identity,path' || pin.path !== file || !pin.identity) fail('Invalid installation preparation');
|
|
282
|
+
let identity = pin.identity;
|
|
283
|
+
const actual = await baselinePin(file);
|
|
284
|
+
if (platform === 'win32' && actual) identity = { ...identity, device: (BigInt(identity.device) & 0xffffffffn).toString() };
|
|
285
|
+
if (JSON.stringify(actual) !== JSON.stringify(identity)) fail('Installation changed while staging');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
let current = null;
|
|
289
|
+
for (const name of ['default-install.json', 'install.json']) {
|
|
290
|
+
const file = path.join(root, name);
|
|
291
|
+
if (!await exists(file)) continue;
|
|
292
|
+
const value = JSON.parse((await readSmall(file, 16384)).toString('utf8').replace(/^\uFEFF/, ''));
|
|
293
|
+
current = value.installation_id ?? null;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
if (expected.root !== root || expected.incarnation !== current) fail('Installation changed while staging');
|
|
297
|
+
if (current === null) {
|
|
298
|
+
if (!Array.isArray(expected.legacy) || !expected.legacy.length || expected.legacy.length > 32) fail('Invalid legacy installation baseline');
|
|
299
|
+
for (const pin of expected.legacy) {
|
|
300
|
+
const actual = await baselinePin(pin.path);
|
|
301
|
+
let identity = pin.identity;
|
|
302
|
+
// Python-origin restrictions retain FILE_ID_INFO's full volume serial;
|
|
303
|
+
// libuv reports its 32-bit component. The engine still checks the full pin.
|
|
304
|
+
if (platform === 'win32' && actual && identity) identity = { ...identity, device: (BigInt(identity.device) & 0xffffffffn).toString() };
|
|
305
|
+
if (JSON.stringify(actual) !== JSON.stringify(identity)) fail('Installation changed while staging');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function pinRegistration(npm, options) {
|
|
311
|
+
if (!npm) return null;
|
|
312
|
+
const file = path.join(npm.root, 'package.json');
|
|
313
|
+
const handle = await fs.open(file, require('node:fs').constants.O_RDONLY | (require('node:fs').constants.O_NOFOLLOW || 0));
|
|
314
|
+
try {
|
|
315
|
+
const held = await handle.stat({ bigint: true }), root = await fs.lstat(npm.root, { bigint: true });
|
|
316
|
+
const identity = { device: held.dev.toString(), inode: held.ino.toString() };
|
|
317
|
+
const source = {
|
|
318
|
+
async validate() {
|
|
319
|
+
const current = await fs.lstat(npm.root, { bigint: true });
|
|
320
|
+
const descriptor = await require('./uninstall-registration').descriptor(options);
|
|
321
|
+
if (!held.isFile() || held.nlink !== 1n || !current.isDirectory() || current.isSymbolicLink()
|
|
322
|
+
|| current.dev !== root.dev || current.ino !== root.ino
|
|
323
|
+
|| JSON.stringify(await baselinePin(file)) !== JSON.stringify(identity)
|
|
324
|
+
|| JSON.stringify(descriptor) !== JSON.stringify(npm)) fail('npm registration changed while staging');
|
|
325
|
+
},
|
|
326
|
+
async absent() {
|
|
327
|
+
try { await fs.lstat(npm.root); return false; }
|
|
328
|
+
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
329
|
+
},
|
|
330
|
+
close: () => handle.close()
|
|
331
|
+
};
|
|
332
|
+
await source.validate();
|
|
333
|
+
return source;
|
|
334
|
+
} catch (error) { await handle.close(); throw error; }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function settlePreparation(preparation, environment, platform, source) {
|
|
338
|
+
try {
|
|
339
|
+
// Only the failed publisher's newly created object is ours. A replacement
|
|
340
|
+
// registration, locator, payload, control inode or foreign file survives.
|
|
341
|
+
if (!preparation.created || !source || !await source.absent()
|
|
342
|
+
|| await captureBaseline(environment, platform, preparation.target)) return;
|
|
343
|
+
for (const [directory, pin] of preparation.directories) {
|
|
344
|
+
await existingCacheDirectory(directory);
|
|
345
|
+
const current = await fs.lstat(directory, { bigint: true });
|
|
346
|
+
if (current.dev !== pin.dev || current.ino !== pin.ino) return;
|
|
347
|
+
}
|
|
348
|
+
const { file, identity, handle } = preparation;
|
|
349
|
+
if (JSON.stringify(await baselinePin(file)) !== JSON.stringify(identity)) return;
|
|
350
|
+
const held = await handle.stat({ bigint: true });
|
|
351
|
+
if (!held.isFile() || held.nlink !== 1n || held.size > 1n) return;
|
|
352
|
+
const bytes = Buffer.alloc(2);
|
|
353
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
354
|
+
// Windows' existing Python byte-range gate initializes this SAME inode.
|
|
355
|
+
if (BigInt(bytesRead) !== held.size || bytesRead === 1 && bytes[0] !== 0x30) return;
|
|
356
|
+
await fs.unlink(file);
|
|
357
|
+
for (const [directory, pin] of preparation.directories) {
|
|
358
|
+
await existingCacheDirectory(directory);
|
|
359
|
+
const current = await fs.lstat(directory, { bigint: true });
|
|
360
|
+
if (current.dev !== pin.dev || current.ino !== pin.ino) break;
|
|
361
|
+
await fs.rmdir(directory);
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
|
365
|
+
} finally { await preparation.handle.close(); }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function reservePreparation(environment, platform, target, source) {
|
|
369
|
+
if (Object.hasOwn(environment, 'STACKBITE_INSTALL_BASELINE')) return null;
|
|
370
|
+
let baseline = await captureBaseline(environment, platform, target);
|
|
371
|
+
if (baseline) {
|
|
372
|
+
environment.STACKBITE_INSTALL_BASELINE = JSON.stringify(baseline);
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
// First use has no locator to pin. Hold the existing installation control
|
|
376
|
+
// file open across staging, with libuv's Windows delete sharing. This reserves
|
|
377
|
+
// object identity only; the bundled engine still takes the kernel gates.
|
|
378
|
+
const root = installationRoot(environment, platform), directory = path.join(root, '.stackbite-update');
|
|
379
|
+
await admitPublication(environment, platform);
|
|
380
|
+
await existingCacheDirectory(directory);
|
|
381
|
+
for (let parent = root; ; parent = path.dirname(parent)) {
|
|
382
|
+
if (await exists(path.join(parent, '.git'))) fail('Refusing installation preparation in a source repository');
|
|
383
|
+
if (parent === path.dirname(parent)) break;
|
|
384
|
+
}
|
|
385
|
+
if (source) await source.validate();
|
|
386
|
+
const firstCreated = await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
387
|
+
await existingCacheDirectory(directory);
|
|
388
|
+
const directories = [];
|
|
389
|
+
if (firstCreated) {
|
|
390
|
+
for (const selected of [directory, root]) {
|
|
391
|
+
// libuv may return a Windows namespace spelling for the first directory.
|
|
392
|
+
const createdName = path.toNamespacedPath(firstCreated), selectedName = path.toNamespacedPath(selected);
|
|
393
|
+
if (selectedName === createdName || selectedName.startsWith(createdName + path.sep)) {
|
|
394
|
+
directories.push([selected, await fs.lstat(selected, { bigint: true })]);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const constants = require('node:fs').constants;
|
|
399
|
+
const file = path.join(directory, 'update.lock');
|
|
400
|
+
let handle, created = false, preparation;
|
|
401
|
+
try {
|
|
402
|
+
handle = await fs.open(file, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | (constants.O_NOFOLLOW || 0), 0o600);
|
|
403
|
+
created = true;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error.code !== 'EEXIST') throw error;
|
|
406
|
+
handle = await fs.open(file, constants.O_RDWR | (constants.O_NOFOLLOW || 0));
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const held = await handle.stat({ bigint: true });
|
|
410
|
+
const identity = await baselinePin(file);
|
|
411
|
+
if (!held.isFile() || held.nlink !== 1n || JSON.stringify(identity) !== JSON.stringify({ device: held.dev.toString(), inode: held.ino.toString() })) fail('Invalid installation preparation');
|
|
412
|
+
preparation = { handle, file, identity, created, directories, target };
|
|
413
|
+
baseline = { root, preparation: { path: file, identity } };
|
|
414
|
+
environment.STACKBITE_INSTALL_BASELINE = JSON.stringify(baseline);
|
|
415
|
+
if (source) await source.validate();
|
|
416
|
+
await admitPublication(environment, platform);
|
|
417
|
+
await assertBaseline(environment, platform);
|
|
418
|
+
return preparation;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (preparation) await settlePreparation(preparation, environment, platform, source);
|
|
421
|
+
else await handle.close();
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function publishCache(payload, target, spec, options) {
|
|
427
|
+
await assertBaseline(options.environment, spec.platform);
|
|
428
|
+
if (options.existing) {
|
|
429
|
+
const root = installationRoot(options.environment, spec.platform);
|
|
430
|
+
for (const name of ['default-install.json', 'install.json']) {
|
|
431
|
+
const locator = path.join(root, name);
|
|
432
|
+
if (!await exists(locator)) continue;
|
|
433
|
+
const saved = JSON.parse((await readSmall(locator, 16384)).toString('utf8').replace(/^\uFEFF/, ''));
|
|
434
|
+
const descriptor = options.environment.STACKBITE_NPM_REGISTRATION;
|
|
435
|
+
if (typeof saved.home === 'string' && path.isAbsolute(saved.home)
|
|
436
|
+
&& typeof saved.state === 'string' && path.isAbsolute(saved.state) && /^[a-f0-9]{32}$/.test(saved.installation_id)
|
|
437
|
+
&& (!descriptor || JSON.stringify(saved.npm_registration) === JSON.stringify(JSON.parse(descriptor)))) return;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const engine = path.join(payload, 'stackbite' + (spec.platform === 'win32' ? '.exe' : ''));
|
|
442
|
+
const argv = options.existing ? ['--record-install', target]
|
|
443
|
+
: ['--record-install', target, '-', payload, 'cache'];
|
|
444
|
+
const result = await launchNative(engine, argv, { ...options.environment, STACKBITE_HOME: payload }, options);
|
|
445
|
+
if (result.code !== 0 || result.signal) fail('Runtime publication was refused');
|
|
446
|
+
}
|
|
447
|
+
|
|
192
448
|
async function ensureBinary(spec, { cache = cacheDirectory(), downloadImpl = download, extractImpl = extractArchive,
|
|
193
|
-
checkOnly = false, reportCached = false, includePhone = false, progress = () => {}
|
|
449
|
+
checkOnly = false, reportCached = false, includePhone = false, progress = () => {},
|
|
450
|
+
environment = process.env, publishImpl = publishCache, registrationSource = null, ...options } = {}) {
|
|
194
451
|
cache = path.resolve(cache);
|
|
452
|
+
environment = { ...environment };
|
|
453
|
+
if (!checkOnly) {
|
|
454
|
+
await admitPublication(environment, spec.platform);
|
|
455
|
+
await existingCacheDirectory(cache); // Validate existing ancestors without creating a cache.
|
|
456
|
+
}
|
|
195
457
|
if (checkOnly) {
|
|
196
458
|
if (!await existingCacheDirectory(cache)) fail('Pinned runtime is not cached; run stackbite setup.');
|
|
197
|
-
}
|
|
459
|
+
}
|
|
198
460
|
// Full identity remains SHA-256 addressed while avoiding Windows MAX_PATH.
|
|
199
461
|
const cacheKey = createHash('sha256').update(JSON.stringify([spec.version, spec.commit, spec.key, spec.sha256])).digest('hex');
|
|
200
462
|
const target = path.join(cache, `v1-${cacheKey}`);
|
|
201
|
-
|
|
202
|
-
if (checkOnly || reportCached) progress('validate');
|
|
203
|
-
const installed = { home: target, executable: await validatePayload(target, spec, { includePhone }) };
|
|
204
|
-
if (checkOnly || reportCached) progress('ready');
|
|
205
|
-
return installed;
|
|
206
|
-
}
|
|
207
|
-
if (checkOnly) fail('Pinned runtime is not cached; run stackbite setup.');
|
|
208
|
-
const staging = await fs.mkdtemp(path.join(cache, '.staging-'));
|
|
463
|
+
const preparation = !checkOnly ? await reservePreparation(environment, spec.platform, target, registrationSource) : null;
|
|
209
464
|
try {
|
|
210
|
-
|
|
211
|
-
await
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
465
|
+
if (registrationSource) await registrationSource.validate();
|
|
466
|
+
if (!checkOnly) await assertBaseline(environment, spec.platform);
|
|
467
|
+
if (await exists(target)) {
|
|
468
|
+
if (checkOnly || reportCached) progress('validate');
|
|
469
|
+
const installed = { home: target, executable: await validatePayload(target, spec, { includePhone }) };
|
|
470
|
+
if (!checkOnly) await publishImpl(target, target, spec, { ...options, environment, existing: true });
|
|
471
|
+
if (checkOnly || reportCached) progress('ready');
|
|
472
|
+
return installed;
|
|
473
|
+
}
|
|
474
|
+
if (checkOnly) fail('Pinned runtime is not cached; run stackbite setup.');
|
|
475
|
+
// An owned system-temp stage keeps downloads out of product caches. Only the
|
|
476
|
+
// verified engine below can create/publish a canonical cache installation.
|
|
477
|
+
await admitPublication(environment, spec.platform);
|
|
478
|
+
await assertBaseline(environment, spec.platform);
|
|
479
|
+
if (registrationSource) await registrationSource.validate();
|
|
480
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'stackbite-npm-'));
|
|
481
|
+
try {
|
|
482
|
+
const archive = path.join(staging, spec.asset), extraction = path.join(staging, 'extract');
|
|
483
|
+
await fs.mkdir(extraction, { mode: 0o700 });
|
|
484
|
+
progress('download');
|
|
485
|
+
await downloadImpl(spec, archive);
|
|
486
|
+
// Recheck at the extraction boundary, including for injected test transports.
|
|
487
|
+
progress('archive');
|
|
488
|
+
if ((await fs.stat(archive)).size !== spec.size || await hashFile(archive, LIMITS.download) !== spec.sha256) fail('Pinned archive SHA-256 or size mismatch');
|
|
489
|
+
progress('extract');
|
|
490
|
+
await extractImpl(archive, extraction, spec);
|
|
491
|
+
const payload = path.join(extraction, spec.root);
|
|
492
|
+
progress('validate');
|
|
493
|
+
await validatePayload(payload, spec);
|
|
494
|
+
progress('publish');
|
|
495
|
+
await admitPublication(environment, spec.platform);
|
|
496
|
+
await assertBaseline(environment, spec.platform);
|
|
497
|
+
if (registrationSource) await registrationSource.validate();
|
|
498
|
+
await publishImpl(payload, target, spec, { ...options, environment, existing: false });
|
|
499
|
+
// Validate at the final path, including a concurrent publisher's payload.
|
|
500
|
+
progress('validate-published');
|
|
501
|
+
const installed = { home: target, executable: await validatePayload(target, spec) };
|
|
502
|
+
progress('ready');
|
|
503
|
+
return installed;
|
|
504
|
+
} finally {
|
|
505
|
+
// Only this mkdtemp handle is owned by this invocation.
|
|
506
|
+
if (path.dirname(staging) !== path.resolve(os.tmpdir()) || !path.basename(staging).startsWith('stackbite-npm-')) fail('Unsafe staging cleanup path');
|
|
507
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
228
508
|
}
|
|
229
|
-
// Validate at the final path, including a concurrent publisher's payload.
|
|
230
|
-
progress('validate-published');
|
|
231
|
-
const installed = { home: target, executable: await validatePayload(target, spec) };
|
|
232
|
-
progress('ready');
|
|
233
|
-
return installed;
|
|
234
509
|
} finally {
|
|
235
|
-
|
|
236
|
-
if (!inside(cache, staging) || path.dirname(staging) !== cache) fail('Unsafe staging cleanup path');
|
|
237
|
-
await fs.rm(staging, { recursive: true, force: true });
|
|
510
|
+
if (preparation) await settlePreparation(preparation, environment, spec.platform, registrationSource);
|
|
238
511
|
}
|
|
239
512
|
}
|
|
240
513
|
async function launchNative(executable, argv, environment, { spawnImpl = spawn, signalSource = process } = {}) {
|
|
@@ -256,22 +529,41 @@ async function main(argv = process.argv.slice(2), options = {}) {
|
|
|
256
529
|
const removing = ['uninstall', 'delete'].includes(argv[0]);
|
|
257
530
|
const manifest = options.manifest || require('../release.json');
|
|
258
531
|
const platform = options.platform || process.platform;
|
|
259
|
-
const environment = options.environment || process.env;
|
|
260
|
-
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
532
|
+
const environment = { ...(options.environment || process.env) };
|
|
533
|
+
// Only the package's existing descriptor producer supplies registration input.
|
|
534
|
+
// The engine validates package membership and tool/file identity before save.
|
|
535
|
+
delete environment.STACKBITE_NPM_REGISTRATION;
|
|
536
|
+
const { descriptor, explicitApply } = require('./uninstall-registration');
|
|
537
|
+
let npm = !removing ? await descriptor(options) : null;
|
|
538
|
+
const registrationSource = await pinRegistration(npm, options);
|
|
539
|
+
try {
|
|
540
|
+
if (npm) environment.STACKBITE_NPM_REGISTRATION = JSON.stringify(npm);
|
|
541
|
+
const spec = releaseSpec(manifest, platform, options.arch || process.arch, options);
|
|
542
|
+
// First install always verifies everything. Cached TUI/runtime starts defer only
|
|
543
|
+
// Phone; explicit phone/setup/removal commands retain the full pinned check.
|
|
544
|
+
let installed;
|
|
545
|
+
try { installed = await timing.phase(environment, 'launcher.ensure', () => ensureBinary(spec, { ...options,
|
|
546
|
+
environment, registrationSource,
|
|
547
|
+
cache: options.cache || cacheDirectory(environment, platform),
|
|
548
|
+
checkOnly: removing || setup?.checkOnly || false, reportCached: Boolean(setup),
|
|
549
|
+
includePhone: removing || Boolean(setup) || argv[0] === 'phone',
|
|
550
|
+
progress: options.progress || stageReporter(options.stderr || process.stderr) })); }
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (!removing) throw error;
|
|
553
|
+
const { resumeWithoutRuntime, registrationOnly } = require('./uninstall-registration');
|
|
554
|
+
const recovered = await resumeWithoutRuntime(argv, environment, launchNative, options);
|
|
555
|
+
if (recovered) return recovered;
|
|
556
|
+
const registration = await registrationOnly(argv, environment, launchNative, options);
|
|
557
|
+
if (registration) return registration;
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
if (setup) return { code: 0, signal: null };
|
|
561
|
+
timing.mark(environment, 'launcher.exec');
|
|
562
|
+
// The bundled runtime owns state discovery and migration for every entrypoint.
|
|
563
|
+
if (removing && explicitApply(argv)) npm = await descriptor(options);
|
|
564
|
+
return launchNative(installed.executable, argv, { ...environment,
|
|
565
|
+
...(npm ? { STACKBITE_NPM_REGISTRATION: JSON.stringify(npm) } : {}),
|
|
566
|
+
STACKBITE_HOME: path.resolve(installed.home) }, options);
|
|
567
|
+
} finally { if (registrationSource) await registrationSource.close(); }
|
|
276
568
|
}
|
|
277
569
|
module.exports = { releaseSpec, validatePayload, ensureBinary, launchNative, main };
|
|
@@ -6,7 +6,7 @@ const { fail } = require('./common');
|
|
|
6
6
|
|
|
7
7
|
function cacheDirectory(environment = process.env, platform = process.platform, home = os.homedir()) {
|
|
8
8
|
return platform === 'win32'
|
|
9
|
-
? path.resolve(environment.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Stackbite', 'cache')
|
|
9
|
+
? path.resolve(environment.LOCALAPPDATA || path.join(environment.USERPROFILE || home, 'AppData', 'Local'), 'Stackbite', 'cache')
|
|
10
10
|
: path.resolve(environment.XDG_CACHE_HOME || path.join(environment.HOME || home, '.cache'), 'stackbite');
|
|
11
11
|
}
|
|
12
12
|
// Check every ancestor without creating anything, including for an absent cache.
|
|
@@ -11,7 +11,11 @@ async function registration(packageRoot, platform = process.platform) {
|
|
|
11
11
|
if (!info.isDirectory() || info.isSymbolicLink() || await fs.realpath(root) !== root) {
|
|
12
12
|
throw new Error('Linked npm registration preserved; remove it through its original package manager');
|
|
13
13
|
}
|
|
14
|
-
const
|
|
14
|
+
const packageFile = path.join(root, 'package.json'), packageInfo = await fs.lstat(packageFile);
|
|
15
|
+
await commandParent(packageFile);
|
|
16
|
+
if (await projectLocalPackage(root)) throw new Error('Source npm package preserved');
|
|
17
|
+
if (!packageInfo.isFile() || packageInfo.isSymbolicLink() || packageInfo.nlink !== 1) throw new Error('Linked npm package registration preserved');
|
|
18
|
+
const metadata = JSON.parse(await fs.readFile(packageFile, 'utf8'));
|
|
15
19
|
if (metadata.name !== 'stackbite') throw new Error('Foreign npm package registration preserved');
|
|
16
20
|
// npx owns its own cache, which is not a global registration.
|
|
17
21
|
const prefix = globalPrefix(modules, platform);
|
|
@@ -19,6 +23,11 @@ async function registration(packageRoot, platform = process.platform) {
|
|
|
19
23
|
return prefix;
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
async function projectLocalPackage(root) {
|
|
27
|
+
try { await fs.lstat(path.join(root, '.git')); return true; }
|
|
28
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; return false; }
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
function globalPrefix(modules, platform) {
|
|
23
32
|
const prefix = path.dirname(modules);
|
|
24
33
|
if (prefix.split(path.sep).includes('_npx')) return null;
|
|
@@ -47,21 +56,321 @@ async function removeRegistration(argv, result, environment, launch, options = {
|
|
|
47
56
|
if (prefix === null) return result;
|
|
48
57
|
const node = options.nodeExecutable || process.execPath;
|
|
49
58
|
const cli = options.npmCli || npmProgram(root, node);
|
|
59
|
+
const backup = await captureCommand(root, prefix, options.platform || process.platform, node, cli, options);
|
|
50
60
|
const removed = await launch(node, [cli, 'uninstall', '--global', '--prefix', prefix,
|
|
51
61
|
'stackbite', '--ignore-scripts', '--offline', '--no-audit', '--no-fund'],
|
|
52
62
|
{ ...environment, npm_config_update_notifier: 'false' }, options);
|
|
53
|
-
|
|
63
|
+
let remains;
|
|
64
|
+
try { await fs.lstat(root); remains = true; } catch (error) { if (error.code !== 'ENOENT') throw error; remains = false; }
|
|
65
|
+
if (remains) {
|
|
66
|
+
await restoreCommand(backup);
|
|
67
|
+
throw new Error('Product removal was accepted; npm registration removal failed');
|
|
68
|
+
}
|
|
69
|
+
for (const item of backup?.shims || []) {
|
|
70
|
+
try {
|
|
71
|
+
const info = await fs.lstat(item.path);
|
|
72
|
+
if (info.dev === item.info.dev && info.ino === item.info.ino) await fs.unlink(item.path);
|
|
73
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
74
|
+
}
|
|
75
|
+
// npm can fail after removing its last owned file. Physical absence settles it.
|
|
76
|
+
void removed;
|
|
54
77
|
return result;
|
|
55
78
|
}
|
|
56
79
|
|
|
80
|
+
async function captureCommand(root, prefix, platform, node, cli, recoveryOptions) {
|
|
81
|
+
const metadata = await fs.readFile(path.join(root, 'package.json'));
|
|
82
|
+
const relative = 'packaging/npm/bin/stackbite.js';
|
|
83
|
+
const bins = JSON.parse(metadata).bin;
|
|
84
|
+
if (!bins || Object.keys(bins).length !== 1 || bins.stackbite !== relative) return null;
|
|
85
|
+
const entry = path.join(root, relative), shims = [];
|
|
86
|
+
await commandParent(entry);
|
|
87
|
+
const entryInfo = await fs.lstat(entry);
|
|
88
|
+
if (!entryInfo.isFile() || entryInfo.isSymbolicLink() || entryInfo.nlink !== 1) throw new Error('Linked npm command preserved');
|
|
89
|
+
const entryData = await fs.readFile(entry);
|
|
90
|
+
for (const shim of platform === 'win32' ? ['stackbite', 'stackbite.cmd', 'stackbite.ps1'].map(name => path.join(prefix, name)) : [path.join(prefix, 'bin', 'stackbite')]) {
|
|
91
|
+
await commandParent(shim);
|
|
92
|
+
let info;
|
|
93
|
+
try { info = await fs.lstat(shim); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
94
|
+
if (info.isSymbolicLink()) {
|
|
95
|
+
const target = await fs.readlink(shim);
|
|
96
|
+
if (path.resolve(path.dirname(shim), target) !== entry) throw new Error('Foreign npm command preserved');
|
|
97
|
+
shims.push({ path: shim, info, target });
|
|
98
|
+
} else {
|
|
99
|
+
const data = await fs.readFile(shim);
|
|
100
|
+
if (!info.isFile() || info.nlink !== 1 || data.length > 128 * 1024 || !data.toString('utf8').replaceAll('\\', '/').includes(relative)) throw new Error('Foreign npm command preserved');
|
|
101
|
+
shims.push({ path: shim, info, data });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!shims.length) return null;
|
|
105
|
+
const options = JSON.stringify({ packageRoot: root, platform, nodeExecutable: node, npmCli: cli,
|
|
106
|
+
installRoot: recoveryOptions.installRoot, cache: recoveryOptions.cache });
|
|
107
|
+
const recovery = Buffer.from(`const m=require('./uninstall-registration'),cp=require('node:child_process');\nconst launch=async(exe,args,env)=>{const r=cp.spawnSync(exe,args,{env,stdio:'inherit'});if(r.error)throw r.error;return {code:r.status,signal:r.signal};};\nmodule.exports.main=async()=>{const r=await m.registrationOnly(process.argv.slice(2),process.env,launch,${options});if(!r)throw Error('Native removal remains incomplete');return r;};\n`);
|
|
108
|
+
const files = [];
|
|
109
|
+
for (const [file, data] of [[path.join(root, 'package.json'), metadata], [entry, entryData],
|
|
110
|
+
[path.join(root, 'packaging/npm/lib/bootstrap.js'), recovery],
|
|
111
|
+
[path.join(root, 'packaging/npm/lib/uninstall-registration.js'), await fs.readFile(__filename)]]) {
|
|
112
|
+
await commandParent(file);
|
|
113
|
+
let info;
|
|
114
|
+
try { info = await fs.lstat(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
115
|
+
if (info && (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)) throw new Error('Linked npm command preserved');
|
|
116
|
+
files.push({ path: file, info, data });
|
|
117
|
+
}
|
|
118
|
+
return { root, info: await fs.lstat(root), prefix, prefixInfo: await fs.lstat(prefix), shims, files };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function restoreCommand(backup) {
|
|
122
|
+
if (!backup) return;
|
|
123
|
+
const info = await fs.lstat(backup.root);
|
|
124
|
+
if (info.dev !== backup.info.dev || info.ino !== backup.info.ino) throw new Error('npm registration replaced; preserved');
|
|
125
|
+
const prefix = await fs.lstat(backup.prefix);
|
|
126
|
+
if (prefix.dev !== backup.prefixInfo.dev || prefix.ino !== backup.prefixInfo.ino) throw new Error('npm prefix replaced; preserved');
|
|
127
|
+
const items = [...backup.files, ...backup.shims];
|
|
128
|
+
for (const item of items) {
|
|
129
|
+
await commandParent(item.path);
|
|
130
|
+
try {
|
|
131
|
+
const current = await fs.lstat(item.path);
|
|
132
|
+
if (!item.info || current.dev !== item.info.dev || current.ino !== item.info.ino) throw new Error('npm command replaced; preserved');
|
|
133
|
+
if (!item.target && (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1)) throw new Error('Linked npm command preserved');
|
|
134
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
135
|
+
}
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
if (item.target) {
|
|
138
|
+
try { await fs.lstat(item.path); } catch (error) { if (error.code !== 'ENOENT') throw error; await fs.symlink(item.target, item.path); }
|
|
139
|
+
} else {
|
|
140
|
+
// An npm partial uninstall can leave this original bin read/share-only.
|
|
141
|
+
// Its existing require('../lib/bootstrap') reaches the small recovery module.
|
|
142
|
+
try { if ((await fs.readFile(item.path)).equals(item.data)) continue; }
|
|
143
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
144
|
+
await fs.mkdir(path.dirname(item.path), { recursive: true });
|
|
145
|
+
await commandParent(item.path);
|
|
146
|
+
const file = await fs.open(item.path, require('node:fs').constants.O_WRONLY | require('node:fs').constants.O_CREAT
|
|
147
|
+
| require('node:fs').constants.O_TRUNC | (require('node:fs').constants.O_NOFOLLOW || 0), 0o755);
|
|
148
|
+
try { await file.writeFile(item.data); } finally { await file.close(); }
|
|
149
|
+
await fs.chmod(item.path, 0o755);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function commandParent(file) {
|
|
155
|
+
for (let parent = path.dirname(file); ; parent = path.dirname(parent)) {
|
|
156
|
+
try { const info = await fs.lstat(parent); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Linked npm command preserved'); }
|
|
157
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
158
|
+
if (parent === path.dirname(parent)) return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
57
162
|
function acceptedRemoval(argv, result) {
|
|
58
163
|
return explicitApply(argv) && !result.signal && [0, 2].includes(result.code);
|
|
59
164
|
}
|
|
60
165
|
|
|
61
166
|
function explicitApply(argv) {
|
|
62
|
-
const
|
|
63
|
-
return
|
|
64
|
-
&& argv.
|
|
167
|
+
const allowed = new Set(['--all', '--keep-data', '--resume', '--apply', '--yes']);
|
|
168
|
+
return ['uninstall', 'delete'].includes(argv[0]) && argv.slice(1).every(arg => allowed.has(arg))
|
|
169
|
+
&& !(argv.includes('--all') && argv.includes('--keep-data'))
|
|
170
|
+
&& !(argv.includes('--resume') && (argv.includes('--all') || argv.includes('--keep-data')));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function descriptor(options = {}) {
|
|
174
|
+
const root = options.packageRoot || path.resolve(__dirname, '../../..');
|
|
175
|
+
const prefix = await registration(root, options.platform || process.platform);
|
|
176
|
+
if (prefix === null) return null;
|
|
177
|
+
const node = options.nodeExecutable || process.execPath;
|
|
178
|
+
return { root, prefix, node: await fs.realpath(node), cli: await fs.realpath(options.npmCli || npmProgram(root, node)) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function windowsCleanupCommand(helper, pin) {
|
|
182
|
+
const encoded = Buffer.from(helper, 'utf8').toString('base64');
|
|
183
|
+
const dispatch = "& { $p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encoded
|
|
184
|
+
+ "')); $b=[IO.File]::ReadAllBytes($p); $s=[Security.Cryptography.SHA256]::Create(); try { "
|
|
185
|
+
+ "$h=[BitConverter]::ToString($s.ComputeHash($b)).Replace('-','').ToLowerInvariant() "
|
|
186
|
+
+ "} finally { $s.Dispose() }; if ($h -ne '" + pin.sha256 + "') { throw 'Removal capsule changed' }; "
|
|
187
|
+
+ "& ([scriptblock]::Create([Text.Encoding]::UTF8.GetString($b).TrimStart([char]0xfeff))) }";
|
|
188
|
+
return ['powershell.exe', '-NoProfile', '-Command', dispatch];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function registrationOnly(argv, environment, launch, options = {}) {
|
|
192
|
+
if (!explicitApply(argv)) return null;
|
|
193
|
+
const platform = options.platform || process.platform;
|
|
194
|
+
const packageRoot = options.packageRoot || path.resolve(__dirname, '../../..');
|
|
195
|
+
const prefix = await registration(packageRoot, platform);
|
|
196
|
+
if (prefix === null) return null;
|
|
197
|
+
const home = (platform === 'win32' ? environment.USERPROFILE || environment.HOME : environment.HOME) || require('node:os').homedir();
|
|
198
|
+
const data = platform === 'win32' ? environment.LOCALAPPDATA || home
|
|
199
|
+
: environment.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
|
200
|
+
const root = options.installRoot || path.join(data, platform === 'win32' ? 'Stackbite' : 'stackbite');
|
|
201
|
+
const canonical = path.join(data, platform === 'win32' ? 'Stackbite' : 'stackbite');
|
|
202
|
+
const old = path.join(data, platform === 'win32' ? 'Helm' : 'helm');
|
|
203
|
+
// Keep this recovery module standalone after npm removes the other modules.
|
|
204
|
+
// This is cache.js's npm namespace, not Python's locator/control fallback.
|
|
205
|
+
const npmData = environment.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
206
|
+
const roots = [root, ...['app', 'cache', 'state', 'default-install.json', 'install.json'].map(name => path.join(old, name)),
|
|
207
|
+
...(platform === 'win32' ? [path.join(npmData, 'Stackbite'),
|
|
208
|
+
...['app', 'cache', 'state', 'default-install.json', 'install.json'].map(name =>
|
|
209
|
+
path.join(npmData, 'Helm', name))] : []),
|
|
210
|
+
...(platform === 'win32' ? [] : ['stackbite', 'helm'].flatMap(name => [
|
|
211
|
+
path.join(environment.XDG_STATE_HOME || path.join(home, '.local', 'state'), name),
|
|
212
|
+
path.join(environment.XDG_CACHE_HOME || path.join(home, '.cache'), name)])),
|
|
213
|
+
...[options.cache, environment.STACKBITE_HOME, environment.STACKBITE_STATE_DIR].filter(Boolean)];
|
|
214
|
+
// Directory shells left by a completed physical tail contain no artifacts.
|
|
215
|
+
// The pinned canonical preparation below is the only permitted file.
|
|
216
|
+
let preparation, preparationHandle;
|
|
217
|
+
async function empty(location) {
|
|
218
|
+
try {
|
|
219
|
+
const info = await fs.lstat(location);
|
|
220
|
+
if (preparation && location === preparation.file) return preparationCurrent();
|
|
221
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await fs.realpath(location) !== path.resolve(location)) return false;
|
|
222
|
+
for (const name of await fs.readdir(location)) if (!await empty(path.join(location, name))) return false;
|
|
223
|
+
return true;
|
|
224
|
+
} catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
225
|
+
}
|
|
226
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
227
|
+
async function preparationCurrent() {
|
|
228
|
+
const held = await preparation.handle.stat({ bigint: true });
|
|
229
|
+
const current = await fs.lstat(preparation.file, { bigint: true });
|
|
230
|
+
const bytes = Buffer.alloc(2);
|
|
231
|
+
const { bytesRead } = await preparation.handle.read(bytes, 0, bytes.length, 0);
|
|
232
|
+
return held.isFile() && current.isFile() && !current.isSymbolicLink()
|
|
233
|
+
&& held.nlink === 1n && current.nlink === 1n && same(held, preparation.info) && same(current, held)
|
|
234
|
+
&& held.size <= 1n && current.size === held.size && BigInt(bytesRead) === held.size
|
|
235
|
+
&& (bytesRead === 0 || bytesRead === 1 && bytes[0] === 0x30);
|
|
236
|
+
}
|
|
237
|
+
async function preparationDirectories() {
|
|
238
|
+
for (let parent = preparation.directory; ; parent = path.dirname(parent)) {
|
|
239
|
+
const info = await fs.lstat(parent);
|
|
240
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await fs.realpath(parent) !== parent) return false;
|
|
241
|
+
try { await fs.lstat(path.join(parent, '.git')); return false; }
|
|
242
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
243
|
+
if (parent === path.dirname(parent)) break;
|
|
244
|
+
}
|
|
245
|
+
for (const [directory, expected, names] of [
|
|
246
|
+
[root, preparation.rootInfo, ['.stackbite-update']],
|
|
247
|
+
[preparation.directory, preparation.directoryInfo, ['update.lock']],
|
|
248
|
+
]) {
|
|
249
|
+
const info = await fs.lstat(directory, { bigint: true });
|
|
250
|
+
if (!info.isDirectory() || info.isSymbolicLink() || !same(info, expected)
|
|
251
|
+
|| await fs.realpath(directory) !== directory
|
|
252
|
+
|| JSON.stringify((await fs.readdir(directory)).sort()) !== JSON.stringify(names)) return false;
|
|
253
|
+
}
|
|
254
|
+
return preparationCurrent();
|
|
255
|
+
}
|
|
256
|
+
// A first-ever publisher pins this existing control object across download.
|
|
257
|
+
// Unlinking its verified name invalidates that publisher; this FD is no mutex.
|
|
258
|
+
const directory = path.join(root, '.stackbite-update'), file = path.join(directory, 'update.lock');
|
|
259
|
+
try {
|
|
260
|
+
try {
|
|
261
|
+
const info = await fs.lstat(file, { bigint: true });
|
|
262
|
+
if (root !== canonical || !path.isAbsolute(root) || path.resolve(root) !== root
|
|
263
|
+
|| root.split(path.sep).includes('..') || /[\0\r\n]/.test(root)
|
|
264
|
+
|| !info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > 1n) return null;
|
|
265
|
+
for (let parent = directory; ; parent = path.dirname(parent)) {
|
|
266
|
+
const parentInfo = await fs.lstat(parent);
|
|
267
|
+
if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink() || await fs.realpath(parent) !== parent) return null;
|
|
268
|
+
try { await fs.lstat(path.join(parent, '.git')); return null; }
|
|
269
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
270
|
+
if (parent === path.dirname(parent)) break;
|
|
271
|
+
}
|
|
272
|
+
preparationHandle = await fs.open(file, require('node:fs').constants.O_RDONLY | (require('node:fs').constants.O_NOFOLLOW || 0));
|
|
273
|
+
preparation = { file, directory, info, handle: preparationHandle, rootInfo: await fs.lstat(root, { bigint: true }),
|
|
274
|
+
directoryInfo: await fs.lstat(directory, { bigint: true }) };
|
|
275
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
276
|
+
if (preparation && !await preparationDirectories()) return null;
|
|
277
|
+
const commands = platform === 'win32' ? ['stackbite.cmd', 'stackbite-portable.cmd', 'stackbite.exe', 'stackbite.exe.next'] : ['stackbite'];
|
|
278
|
+
for (const name of commands) {
|
|
279
|
+
const native = path.join(home, '.local', 'bin', name);
|
|
280
|
+
const own = platform === 'win32' ? path.join(prefix, name) : path.join(prefix, 'bin', name);
|
|
281
|
+
if (native !== own) roots.push(native);
|
|
282
|
+
}
|
|
283
|
+
for (const location of new Set(roots)) {
|
|
284
|
+
const absolute = path.resolve(location);
|
|
285
|
+
for (let parent = path.dirname(absolute); ; parent = path.dirname(parent)) {
|
|
286
|
+
try { const info = await fs.lstat(parent); if (!info.isDirectory() || info.isSymbolicLink()) return null; }
|
|
287
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
288
|
+
if (parent === path.dirname(parent)) break;
|
|
289
|
+
}
|
|
290
|
+
if (!await empty(absolute)) return null;
|
|
291
|
+
}
|
|
292
|
+
if (preparation) {
|
|
293
|
+
if (!await preparationDirectories()) return null;
|
|
294
|
+
await fs.unlink(preparation.file);
|
|
295
|
+
for (const [location, expected] of [[directory, preparation.directoryInfo], [root, preparation.rootInfo]]) {
|
|
296
|
+
const current = await fs.lstat(location, { bigint: true });
|
|
297
|
+
if (!same(current, expected) || !current.isDirectory() || current.isSymbolicLink()
|
|
298
|
+
|| await fs.realpath(location) !== location) throw new Error('Installation preparation replaced; preserved');
|
|
299
|
+
await fs.rmdir(location);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return removeRegistration(argv, { code: 0, signal: null }, environment, launch, { ...options, packageRoot });
|
|
303
|
+
} finally { if (preparationHandle) await preparationHandle.close(); }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Recovery never installs/downloads another runtime. Only the accepted journal's
|
|
307
|
+
// exact capsule can continue a partially removed product.
|
|
308
|
+
async function resumeWithoutRuntime(argv, environment, launch, options = {}) {
|
|
309
|
+
if (!explicitApply(argv)) return null;
|
|
310
|
+
const platform = options.platform || process.platform;
|
|
311
|
+
const home = (platform === 'win32' ? environment.USERPROFILE || environment.HOME : environment.HOME) || require('node:os').homedir();
|
|
312
|
+
const root = options.installRoot || (platform === 'win32'
|
|
313
|
+
? path.join(environment.LOCALAPPDATA || home, 'Stackbite')
|
|
314
|
+
: path.join(environment.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'stackbite'));
|
|
315
|
+
const journal = path.join(root, '.stackbite-uninstall.json');
|
|
316
|
+
let record;
|
|
317
|
+
try {
|
|
318
|
+
const info = await fs.lstat(journal);
|
|
319
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 8 * 1024 * 1024
|
|
320
|
+
|| await fs.realpath(journal) !== journal) throw new Error('Unsafe removal journal');
|
|
321
|
+
record = JSON.parse(await fs.readFile(journal, 'utf8'));
|
|
322
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
323
|
+
if (record.schema !== 'stackbite.uninstall.v1' || !record.accepted || !record.processes_settled
|
|
324
|
+
|| record.install_identity?.root !== root) return null;
|
|
325
|
+
// The capsule takes the existing installation/removal locks and owns all
|
|
326
|
+
// journal mutation, including npm settlement. This reader never rewrites it.
|
|
327
|
+
const command = record.recovery_command;
|
|
328
|
+
const suffix = platform === 'win32' ? '.ps1' : '.sh';
|
|
329
|
+
const helper = journal.replace(/\.json$/, suffix);
|
|
330
|
+
const pin = record.helper_files?.find(item => item.path === helper);
|
|
331
|
+
if (!pin) return null;
|
|
332
|
+
const expected = platform === 'win32' ? windowsCleanupCommand(helper, pin) : ['sh', helper];
|
|
333
|
+
const legacy = ['powershell.exe', '-NoProfile', '-File', helper];
|
|
334
|
+
const selected = platform === 'win32' && JSON.stringify(command) === JSON.stringify(legacy) ? legacy : expected;
|
|
335
|
+
if (!Array.isArray(command) || JSON.stringify(command) !== JSON.stringify(selected)
|
|
336
|
+
|| !record.helper_files?.some(item => item.path === helper)) return null;
|
|
337
|
+
const info = await fs.lstat(helper);
|
|
338
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 128 * 1024
|
|
339
|
+
|| await fs.realpath(helper) !== helper
|
|
340
|
+
|| require('node:crypto').createHash('sha256').update(await fs.readFile(helper)).digest('hex') !== pin.sha256) {
|
|
341
|
+
throw new Error('Removal capsule changed; preserved');
|
|
342
|
+
}
|
|
343
|
+
if (platform !== 'win32') {
|
|
344
|
+
const capsule = journal.replace(/\.json$/, '.cleanup');
|
|
345
|
+
const pinned = record.helper_files.find(item => item.path === capsule);
|
|
346
|
+
const current = await fs.lstat(capsule);
|
|
347
|
+
if (!pinned || !current.isFile() || current.isSymbolicLink() || current.nlink !== 1
|
|
348
|
+
|| await fs.realpath(capsule) !== capsule
|
|
349
|
+
|| require('node:crypto').createHash('sha256').update(await fs.readFile(capsule)).digest('hex') !== pinned.sha256) {
|
|
350
|
+
throw new Error('Removal capsule changed; preserved');
|
|
351
|
+
}
|
|
352
|
+
// Source invocations retain their existing interpreter/entry outside the
|
|
353
|
+
// removal inventory. Validate those exact pins before executing the shell
|
|
354
|
+
// capsule; neither environment PATH nor journal argv selects a runtime.
|
|
355
|
+
if (record.source_cleanup_pins !== undefined) {
|
|
356
|
+
if (!Array.isArray(record.source_cleanup_pins) || record.source_cleanup_pins.length !== 4) {
|
|
357
|
+
throw new Error('Source cleanup identity changed');
|
|
358
|
+
}
|
|
359
|
+
for (const pin of record.source_cleanup_pins) {
|
|
360
|
+
const location = pin.path;
|
|
361
|
+
if (typeof location !== 'string' || !path.isAbsolute(location) || /[\x00\r\n]/.test(location)
|
|
362
|
+
|| location.split(path.sep).includes('..')) throw new Error('Source cleanup runtime changed');
|
|
363
|
+
const current = await fs.lstat(location);
|
|
364
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1
|
|
365
|
+
|| await fs.realpath(location) !== location || current.dev !== pin.identity?.device
|
|
366
|
+
|| current.ino !== pin.identity?.inode
|
|
367
|
+
|| require('node:crypto').createHash('sha256').update(await fs.readFile(location)).digest('hex') !== pin.sha256) {
|
|
368
|
+
throw new Error('Source cleanup runtime changed');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return launch(selected[0], selected.slice(1), environment, options);
|
|
65
374
|
}
|
|
66
375
|
|
|
67
|
-
module.exports = { registration, npmProgram, removeRegistration, explicitApply };
|
|
376
|
+
module.exports = { registration, npmProgram, removeRegistration, explicitApply, descriptor, resumeWithoutRuntime, registrationOnly };
|
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"commit": "
|
|
3
|
+
"version": "0.5.96",
|
|
4
|
+
"commit": "7f23d4a4e4d940e0147d841992e93dd23befc944",
|
|
5
5
|
"repository": "Shinick-Han/stackbite",
|
|
6
6
|
"platforms": {
|
|
7
7
|
"win32-x64": {
|
|
8
|
-
"asset": "stackbite-0.5.
|
|
9
|
-
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.
|
|
10
|
-
"sha256": "
|
|
11
|
-
"size":
|
|
12
|
-
"root": "stackbite-0.5.
|
|
13
|
-
"manifestSha256": "
|
|
14
|
-
"metadataSha256": "
|
|
8
|
+
"asset": "stackbite-0.5.96-windows-x64-7f23d4a4e4d9.zip",
|
|
9
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.96/stackbite-0.5.96-windows-x64-7f23d4a4e4d9.zip",
|
|
10
|
+
"sha256": "ea9b75271f5fbb2ba082eb323adecbacf53a0b049330be2e99fefe865fc6dba0",
|
|
11
|
+
"size": 95635099,
|
|
12
|
+
"root": "stackbite-0.5.96-windows-x64-7f23d4a4e4d9",
|
|
13
|
+
"manifestSha256": "464afb4d132173090c27e4fb2546801cf469b4659e1df68b51fad92555ceaac4",
|
|
14
|
+
"metadataSha256": "5d741796ed25420cb01d0ba736a3d54a94609369702f58c0770c1dc23241aa75"
|
|
15
15
|
},
|
|
16
16
|
"linux-x64": {
|
|
17
|
-
"asset": "stackbite-0.5.
|
|
18
|
-
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.
|
|
19
|
-
"sha256": "
|
|
20
|
-
"size":
|
|
21
|
-
"root": "stackbite-0.5.
|
|
22
|
-
"manifestSha256": "
|
|
23
|
-
"metadataSha256": "
|
|
17
|
+
"asset": "stackbite-0.5.96-linux-x86_64-7f23d4a4e4d9.tar.gz",
|
|
18
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.96/stackbite-0.5.96-linux-x86_64-7f23d4a4e4d9.tar.gz",
|
|
19
|
+
"sha256": "2cd509a933634d270da4e1729022beb7b7a803880617088c94327d2ad4631e38",
|
|
20
|
+
"size": 75907031,
|
|
21
|
+
"root": "stackbite-0.5.96-linux-x86_64-7f23d4a4e4d9",
|
|
22
|
+
"manifestSha256": "d59dd6704b0ada8a426f5fab557728d2fef7008cecf076c45662c6292546b6b7",
|
|
23
|
+
"metadataSha256": "d1afa491f2d0d1f3f128e9b8bef24b62c2adbd3a3ea84fef5692552f303063b2"
|
|
24
24
|
},
|
|
25
25
|
"darwin-arm64": {
|
|
26
|
-
"asset": "stackbite-0.5.
|
|
27
|
-
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.
|
|
28
|
-
"sha256": "
|
|
29
|
-
"size":
|
|
30
|
-
"root": "stackbite-0.5.
|
|
31
|
-
"manifestSha256": "
|
|
32
|
-
"metadataSha256": "
|
|
26
|
+
"asset": "stackbite-0.5.96-macos-arm64-7f23d4a4e4d9.tar.gz",
|
|
27
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.96/stackbite-0.5.96-macos-arm64-7f23d4a4e4d9.tar.gz",
|
|
28
|
+
"sha256": "822879f121af836b47152d7def5e59e50fcb1a8ed987090d1efff4c3688e3ed2",
|
|
29
|
+
"size": 30871909,
|
|
30
|
+
"root": "stackbite-0.5.96-macos-arm64-7f23d4a4e4d9",
|
|
31
|
+
"manifestSha256": "e25fc4c20e124d2db018d8402cb5590368e634afc0b6bc035db665ee0a54578c",
|
|
32
|
+
"metadataSha256": "e6ef71691f4d36ad244864d2cbfb2f916e7afafda8c59787926399b6539e2562"
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
}
|