multiplane 0.3.6 → 0.3.7

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.
Files changed (67) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD_PARTY_NOTICES.md +30 -30
  3. package/examples/alpha.html +18 -18
  4. package/examples/animation.html +17 -17
  5. package/examples/assets/OFL.txt +93 -93
  6. package/examples/assets/card.svg +6 -6
  7. package/examples/three-cards.html +17 -17
  8. package/examples/typography.html +17 -17
  9. package/licenses/GPL-3.0.txt +232 -232
  10. package/licenses/LGPL-3.0.txt +304 -304
  11. package/licenses/acorn-LICENSE +21 -21
  12. package/licenses/colour-LICENSE.md +82 -82
  13. package/licenses/detect-libc-LICENSE +201 -201
  14. package/licenses/entities-LICENSE +11 -11
  15. package/licenses/nanoid-LICENSE +20 -20
  16. package/licenses/parse5-LICENSE +19 -19
  17. package/licenses/picocolors-LICENSE +15 -15
  18. package/licenses/playwright-NOTICE +5 -5
  19. package/licenses/playwright-ThirdPartyNotices.txt +14 -14
  20. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
  21. package/licenses/postcss-LICENSE +20 -20
  22. package/licenses/postcss-value-parser-LICENSE +22 -22
  23. package/licenses/semver-LICENSE +15 -15
  24. package/licenses/sharp-LICENSE +191 -191
  25. package/licenses/sharp-win32-x64-NOTICES.md +45 -45
  26. package/licenses/source-map-js-LICENSE +28 -28
  27. package/licenses/three-LICENSE +21 -21
  28. package/package.json +92 -92
  29. package/src/alpha.mjs +69 -69
  30. package/src/artifacts.mjs +61 -61
  31. package/src/browser-recovery.mjs +26 -26
  32. package/src/capture.mjs +12 -12
  33. package/src/encoder.mjs +16 -16
  34. package/src/environment.mjs +56 -56
  35. package/src/events.mjs +5 -5
  36. package/src/ffmpeg.mjs +32 -32
  37. package/src/format.mjs +27 -27
  38. package/src/frame-rate.mjs +28 -28
  39. package/src/home-migration.mjs +54 -54
  40. package/src/hosted.mjs +1 -1
  41. package/src/lint.mjs +248 -248
  42. package/src/output-alpha.mjs +36 -36
  43. package/src/output-check.mjs +110 -110
  44. package/src/platform.mjs +114 -114
  45. package/src/png-native.mjs +12 -12
  46. package/src/png.mjs +74 -74
  47. package/src/post.mjs +38 -37
  48. package/src/preview.mjs +23 -23
  49. package/src/process.mjs +9 -9
  50. package/src/render-pool.mjs +2 -2
  51. package/src/render-worker.mjs +1 -1
  52. package/src/resource-path.mjs +29 -29
  53. package/src/resources.mjs +27 -27
  54. package/src/runtime/camera.js +37 -37
  55. package/src/runtime/shot.css +9 -9
  56. package/src/server.mjs +7 -7
  57. package/src/setup.mjs +51 -51
  58. package/src/skills.mjs +128 -128
  59. package/src/stills.mjs +1 -1
  60. package/src/three/index.js +39 -31
  61. package/src/verify.mjs +2 -2
  62. package/src/video-pipe.mjs +6 -6
  63. package/tools/bench/shots/canvas-2d.html +1 -1
  64. package/tools/bench/shots/dom-light.html +1 -1
  65. package/tools/bench/shots/scene.js +44 -44
  66. package/tools/bench/shots/webgl.html +1 -1
  67. package/tools/install-browser.mjs +18 -18
@@ -1,56 +1,56 @@
1
- import { chromium } from 'playwright';
2
- import { browserOptions, browserHint } from './platform.mjs';
3
- import { MultiplaneError } from './errors.mjs';
4
- export async function launchBrowser(options = {}, launcher = chromium, platform = process.platform) {
5
- let browser;
6
- try {
7
- try {
8
- browser = await launcher.launch(browserOptions(options.raster ?? 'gpu', options, platform));
9
- if (platform !== 'linux' || options.raster === 'software' || (await environment(browser)).gpu.hardware) return browser;
10
- await browser.close(); browser = undefined;
11
- } catch (error) {
12
- await browser?.close(); browser = undefined;
13
- if (platform !== 'linux' || options.raster === 'software' || /Executable doesn't exist/.test(error.message)) throw error;
14
- }
15
- // ANGLE can launch without a working GL device on a headless Linux host.
16
- // Relaunch with SwiftShader so WebGL shots remain usable and repeatable.
17
- return await launcher.launch(browserOptions('software', options, platform));
18
- } catch (cause) { throw new MultiplaneError(/Executable doesn't exist/.test(cause.message) ? 'browser_not_installed' : 'browser_launch_failed', undefined,
19
- { cause, hint: browserHint(/Executable doesn't exist/.test(cause.message), platform), details: { reason: cause.message.split(/\r?\n/)[0] } }); }
20
- }
21
- export async function environment(browser, ffmpeg = null) {
22
- const session = await browser.newBrowserCDPSession();
23
- try {
24
- const { gpu } = await session.send('SystemInfo.getInfo');
25
- const renderer = gpu.auxAttributes?.glRenderer ?? gpu.devices?.[0]?.deviceString ?? 'unknown';
26
- return { node: process.version, platform: process.platform, chromium: browser.version(),
27
- gpu: { renderer, hardware: !/swiftshader|llvmpipe|software|unknown/i.test(renderer) }, ffmpeg };
28
- } finally { await session.detach(); }
29
- }
30
- export async function loaded(events, capture, ffmpeg = null) {
31
- events.options.alpha = capture.alpha;
32
- events.start(true);
33
- const { width, height, duration, fps, cuts, post } = capture;
34
- events.emit('shot', { width, height, duration, fps, cuts, post });
35
- events.check();
36
- }
37
-
38
- export function warnGpuFallback(events, info, options = {}) {
39
- if ((options.raster ?? 'gpu') === 'gpu' && !info.gpu.hardware) {
40
- events.emit('warning', { code: 'gpu_software_fallback', message: 'Hardware GPU rasterization is unavailable.' });
41
- }
42
- }
43
-
44
- export const observeBrowser = (events, ffmpeg = null) => {
45
- let observed = false;
46
- return async browser => {
47
- const unsubscribe = events.onCancel(() => browser.close());
48
- browser.once('disconnected', unsubscribe);
49
- events.check();
50
- if (observed) return;
51
- const info = await environment(browser, ffmpeg);
52
- observed = true;
53
- events.emit('environment', info);
54
- warnGpuFallback(events, info, events.options);
55
- };
56
- };
1
+ import { chromium } from 'playwright';
2
+ import { browserOptions, browserHint } from './platform.mjs';
3
+ import { MultiplaneError } from './errors.mjs';
4
+ export async function launchBrowser(options = {}, launcher = chromium, platform = process.platform) {
5
+ let browser;
6
+ try {
7
+ try {
8
+ browser = await launcher.launch(browserOptions(options.raster ?? 'gpu', options, platform));
9
+ if (platform !== 'linux' || options.raster === 'software' || (await environment(browser)).gpu.hardware) return browser;
10
+ await browser.close(); browser = undefined;
11
+ } catch (error) {
12
+ await browser?.close(); browser = undefined;
13
+ if (platform !== 'linux' || options.raster === 'software' || /Executable doesn't exist/.test(error.message)) throw error;
14
+ }
15
+ // ANGLE can launch without a working GL device on a headless Linux host.
16
+ // Relaunch with SwiftShader so WebGL shots remain usable and repeatable.
17
+ return await launcher.launch(browserOptions('software', options, platform));
18
+ } catch (cause) { throw new MultiplaneError(/Executable doesn't exist/.test(cause.message) ? 'browser_not_installed' : 'browser_launch_failed', undefined,
19
+ { cause, hint: browserHint(/Executable doesn't exist/.test(cause.message), platform), details: { reason: cause.message.split(/\r?\n/)[0] } }); }
20
+ }
21
+ export async function environment(browser, ffmpeg = null) {
22
+ const session = await browser.newBrowserCDPSession();
23
+ try {
24
+ const { gpu } = await session.send('SystemInfo.getInfo');
25
+ const renderer = gpu.auxAttributes?.glRenderer ?? gpu.devices?.[0]?.deviceString ?? 'unknown';
26
+ return { node: process.version, platform: process.platform, chromium: browser.version(),
27
+ gpu: { renderer, hardware: !/swiftshader|llvmpipe|software|unknown/i.test(renderer) }, ffmpeg };
28
+ } finally { await session.detach(); }
29
+ }
30
+ export async function loaded(events, capture, ffmpeg = null) {
31
+ events.options.alpha = capture.alpha;
32
+ events.start(true);
33
+ const { width, height, duration, fps, cuts, post } = capture;
34
+ events.emit('shot', { width, height, duration, fps, cuts, post });
35
+ events.check();
36
+ }
37
+
38
+ export function warnGpuFallback(events, info, options = {}) {
39
+ if ((options.raster ?? 'gpu') === 'gpu' && !info.gpu.hardware) {
40
+ events.emit('warning', { code: 'gpu_software_fallback', message: 'Hardware GPU rasterization is unavailable.' });
41
+ }
42
+ }
43
+
44
+ export const observeBrowser = (events, ffmpeg = null) => {
45
+ let observed = false;
46
+ return async browser => {
47
+ const unsubscribe = events.onCancel(() => browser.close());
48
+ browser.once('disconnected', unsubscribe);
49
+ events.check();
50
+ if (observed) return;
51
+ const info = await environment(browser, ffmpeg);
52
+ observed = true;
53
+ events.emit('environment', info);
54
+ warnGpuFallback(events, info, events.options);
55
+ };
56
+ };
package/src/events.mjs CHANGED
@@ -221,17 +221,17 @@ export async function runCommand(command, shot, options, action, job = true) {
221
221
  }
222
222
  }
223
223
  const normalized = { ...Object.fromEntries(Object.keys(explicit).filter(key => key !== '_hostedCLI').map(key => [key, options[key]])), draft: true };
224
- Object.assign(events.options, await applyDraft(command, shot, normalized, options, {
224
+ Object.assign(events.options, await applyDraft(command, shot, normalized, options, {
225
225
  check: events.check,
226
226
  onBrowser: browser => {
227
227
  const unsubscribe = events.onCancel(() => browser.close());
228
228
  browser.once('disconnected', unsubscribe);
229
229
  events.check();
230
230
  },
231
- }));
232
- // Draft merges explicit options again; retain the absolute output path
233
- // required by atomic directory publication and artifact relocation.
234
- events.options.out = path.resolve(events.options.out);
231
+ }));
232
+ // Draft merges explicit options again; retain the absolute output path
233
+ // required by atomic directory publication and artifact relocation.
234
+ events.options.out = path.resolve(events.options.out);
235
235
  }
236
236
  events.start();
237
237
  for (const warning of user.warnings) events.warning(warning);
package/src/ffmpeg.mjs CHANGED
@@ -1,32 +1,32 @@
1
- import { spawn } from 'node:child_process';
2
- import { MultiplaneError } from './errors.mjs';
3
- import { ffmpegName, ffmpegHint } from './platform.mjs';
4
-
5
- export function ffmpegOutput(executable, args, env = process.env) {
6
- return new Promise((resolve, reject) => {
7
- const child = spawn(executable, args, { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
8
- const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
9
- child.once('close', () => clearTimeout(timer));
10
- child.once('error', () => clearTimeout(timer));
11
- let output = '';
12
- child.stdout.on('data', bytes => { output += bytes; });
13
- child.stderr.on('data', bytes => { output += bytes; });
14
- child.once('error', reject);
15
- child.once('close', code => code === 0 ? resolve(output) : reject(new Error(`FFmpeg exited with code ${code}.`)));
16
- });
17
- }
18
- export async function inspectFFmpeg(explicit, env = process.env, encoder, output = ffmpegOutput) {
19
- const executable = explicit ?? env.MULTIPLANE_FFMPEG ?? ffmpegName();
20
- try {
21
- const version = (await output(executable, ['-version'], env)).split(/\r?\n/)[0];
22
- if (encoder && !new RegExp(`\\b${encoder}\\b`).test(await output(executable, ['-encoders'], env))) throw new Error(`Missing encoder: ${encoder}`);
23
- return { path: executable, version };
24
- } catch (cause) {
25
- throw new MultiplaneError(cause.code === 'ENOENT' ? 'ffmpeg_not_found' : 'ffmpeg_unusable', undefined,
26
- { cause, hint: cause.message.startsWith('Missing encoder:') ? `${cause.message}. Use an FFmpeg build that includes it.` : ffmpegHint(), details: { reason: cause.message } });
27
- }
28
- }
29
-
30
- export async function resolveFFmpeg(explicit, env = process.env) {
31
- return (await inspectFFmpeg(explicit, env)).path;
32
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { MultiplaneError } from './errors.mjs';
3
+ import { ffmpegName, ffmpegHint } from './platform.mjs';
4
+
5
+ export function ffmpegOutput(executable, args, env = process.env) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(executable, args, { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
8
+ const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
9
+ child.once('close', () => clearTimeout(timer));
10
+ child.once('error', () => clearTimeout(timer));
11
+ let output = '';
12
+ child.stdout.on('data', bytes => { output += bytes; });
13
+ child.stderr.on('data', bytes => { output += bytes; });
14
+ child.once('error', reject);
15
+ child.once('close', code => code === 0 ? resolve(output) : reject(new Error(`FFmpeg exited with code ${code}.`)));
16
+ });
17
+ }
18
+ export async function inspectFFmpeg(explicit, env = process.env, encoder, output = ffmpegOutput) {
19
+ const executable = explicit ?? env.MULTIPLANE_FFMPEG ?? ffmpegName();
20
+ try {
21
+ const version = (await output(executable, ['-version'], env)).split(/\r?\n/)[0];
22
+ if (encoder && !new RegExp(`\\b${encoder}\\b`).test(await output(executable, ['-encoders'], env))) throw new Error(`Missing encoder: ${encoder}`);
23
+ return { path: executable, version };
24
+ } catch (cause) {
25
+ throw new MultiplaneError(cause.code === 'ENOENT' ? 'ffmpeg_not_found' : 'ffmpeg_unusable', undefined,
26
+ { cause, hint: cause.message.startsWith('Missing encoder:') ? `${cause.message}. Use an FFmpeg build that includes it.` : ffmpegHint(), details: { reason: cause.message } });
27
+ }
28
+ }
29
+
30
+ export async function resolveFFmpeg(explicit, env = process.env) {
31
+ return (await inspectFFmpeg(explicit, env)).path;
32
+ }
package/src/format.mjs CHANGED
@@ -1,27 +1,27 @@
1
- import path from 'node:path';
2
- import { MultiplaneError, invalidOption } from './errors.mjs';
3
- import { encoderArgs } from './encoder.mjs';
4
-
5
- export function resolveFormat(options = {}) {
6
- const extension = path.extname(options.out ?? '').slice(1).toLowerCase();
7
- const formats = ['mp4', 'webm', 'mov'];
8
- const format = options.format ?? (formats.includes(extension) ? extension : 'mp4');
9
- if (!formats.includes(format)) throw invalidOption('--format', '--format must be mp4, webm or mov.');
10
- // TODO(design): Document rejection of unknown output extensions as well as conflicts.
11
- if (extension && extension !== format) throw invalidOption('--out', '--out extension must match --format.');
12
- if (format !== 'mp4' && options.encoder !== undefined) throw invalidOption('--encoder', '--encoder only applies to mp4; omit it for webm or mov.');
13
- // TODO(design): Define bitrate controls for the fixed-codec formats if needed.
14
- if (format !== 'mp4' && options['max-bitrate'] !== undefined) throw invalidOption('--max-bitrate', '--max-bitrate currently only applies to mp4.');
15
- if (format === 'mp4' && options.alpha) throw new MultiplaneError('invalid_option', 'MP4 does not support alpha.', { details: { option: '--alpha' }, hint: 'Use --format webm or --format mov and a matching --out extension.' });
16
- return format;
17
- }
18
-
19
- export function videoEncoding(options) {
20
- const { format, alpha } = options;
21
- if (format === 'webm') {
22
- // TODO(design): Document the fixed VP9 quality preset and audio codec.
23
- return { encoder: 'libvpx-vp9', args: ['-c:v', 'libvpx-vp9', '-crf', alpha ? '0' : '16', '-b:v', '0', '-auto-alt-ref', '0', ...(alpha ? ['-lossless', '1'] : [])], pixelFormat: alpha ? 'yuva420p' : 'yuv420p', mux: [], audio: ['-c:a', 'libopus', '-b:a', '320k', '-ac', '2'] };
24
- }
25
- if (format === 'mov') return { encoder: 'prores_ks', args: ['-c:v', 'prores_ks', '-profile:v', '4'], pixelFormat: alpha ? 'yuva444p10le' : 'yuv444p10le', mux: ['-movflags', '+faststart'], audio: ['-c:a', 'aac', '-b:a', '320k', '-ac', '2'] };
26
- return { encoder: options.encoder ?? 'x264', args: encoderArgs(options.encoder ?? 'x264', undefined, options['max-bitrate']), pixelFormat: 'yuv420p', mux: ['-movflags', '+faststart'], audio: ['-c:a', 'aac', '-b:a', '320k', '-ac', '2'] };
27
- }
1
+ import path from 'node:path';
2
+ import { MultiplaneError, invalidOption } from './errors.mjs';
3
+ import { encoderArgs } from './encoder.mjs';
4
+
5
+ export function resolveFormat(options = {}) {
6
+ const extension = path.extname(options.out ?? '').slice(1).toLowerCase();
7
+ const formats = ['mp4', 'webm', 'mov'];
8
+ const format = options.format ?? (formats.includes(extension) ? extension : 'mp4');
9
+ if (!formats.includes(format)) throw invalidOption('--format', '--format must be mp4, webm or mov.');
10
+ // TODO(design): Document rejection of unknown output extensions as well as conflicts.
11
+ if (extension && extension !== format) throw invalidOption('--out', '--out extension must match --format.');
12
+ if (format !== 'mp4' && options.encoder !== undefined) throw invalidOption('--encoder', '--encoder only applies to mp4; omit it for webm or mov.');
13
+ // TODO(design): Define bitrate controls for the fixed-codec formats if needed.
14
+ if (format !== 'mp4' && options['max-bitrate'] !== undefined) throw invalidOption('--max-bitrate', '--max-bitrate currently only applies to mp4.');
15
+ if (format === 'mp4' && options.alpha) throw new MultiplaneError('invalid_option', 'MP4 does not support alpha.', { details: { option: '--alpha' }, hint: 'Use --format webm or --format mov and a matching --out extension.' });
16
+ return format;
17
+ }
18
+
19
+ export function videoEncoding(options) {
20
+ const { format, alpha } = options;
21
+ if (format === 'webm') {
22
+ // TODO(design): Document the fixed VP9 quality preset and audio codec.
23
+ return { encoder: 'libvpx-vp9', args: ['-c:v', 'libvpx-vp9', '-crf', alpha ? '0' : '16', '-b:v', '0', '-auto-alt-ref', '0', ...(alpha ? ['-lossless', '1'] : [])], pixelFormat: alpha ? 'yuva420p' : 'yuv420p', mux: [], audio: ['-c:a', 'libopus', '-b:a', '320k', '-ac', '2'] };
24
+ }
25
+ if (format === 'mov') return { encoder: 'prores_ks', args: ['-c:v', 'prores_ks', '-profile:v', '4'], pixelFormat: alpha ? 'yuva444p10le' : 'yuv444p10le', mux: ['-movflags', '+faststart'], audio: ['-c:a', 'aac', '-b:a', '320k', '-ac', '2'] };
26
+ return { encoder: options.encoder ?? 'x264', args: encoderArgs(options.encoder ?? 'x264', undefined, options['max-bitrate']), pixelFormat: 'yuv420p', mux: ['-movflags', '+faststart'], audio: ['-c:a', 'aac', '-b:a', '320k', '-ac', '2'] };
27
+ }
@@ -1,28 +1,28 @@
1
- import { invalidOption } from './errors.mjs';
2
-
3
- // Encoding time only: capture and post-processing must keep the requested fps.
4
- // Snap NTSC shorthand within 0.0001 fps of n * 1000 / 1001. Otherwise round
5
- // to six decimal places (at most 0.0000005 fps error) and reduce the fraction.
6
- // FFmpeg's AVRational stores both terms as signed 32-bit integers.
7
- export function frameRate(fps) {
8
- if (!Number.isFinite(fps) || fps <= 0) throw invalidOption('--fps', '--fps must be positive and finite.');
9
- const nominal = Math.round(fps * 1001 / 1000);
10
- let numerator, denominator;
11
- if (nominal > 0 && Math.abs(fps - nominal * 1000 / 1001) <= 0.0001) {
12
- numerator = nominal * 1000;
13
- denominator = 1001;
14
- } else {
15
- numerator = Math.round(fps * 1e6);
16
- denominator = 1e6;
17
- }
18
- if (!Number.isSafeInteger(numerator) || numerator <= 0) throw invalidOption('--fps', 'Frame rate cannot be represented at micro-fps precision.');
19
- let a = numerator, b = denominator;
20
- while (b) [a, b] = [b, a % b];
21
- numerator /= a; denominator /= a;
22
- if (numerator > 2147483647) throw invalidOption('--fps', 'Frame rate exceeds FFmpeg rational precision.');
23
- return {
24
- numerator, denominator, argument: `${numerator}/${denominator}`,
25
- fps: numerator / denominator,
26
- seconds: frames => frames * denominator / numerator,
27
- };
28
- }
1
+ import { invalidOption } from './errors.mjs';
2
+
3
+ // Encoding time only: capture and post-processing must keep the requested fps.
4
+ // Snap NTSC shorthand within 0.0001 fps of n * 1000 / 1001. Otherwise round
5
+ // to six decimal places (at most 0.0000005 fps error) and reduce the fraction.
6
+ // FFmpeg's AVRational stores both terms as signed 32-bit integers.
7
+ export function frameRate(fps) {
8
+ if (!Number.isFinite(fps) || fps <= 0) throw invalidOption('--fps', '--fps must be positive and finite.');
9
+ const nominal = Math.round(fps * 1001 / 1000);
10
+ let numerator, denominator;
11
+ if (nominal > 0 && Math.abs(fps - nominal * 1000 / 1001) <= 0.0001) {
12
+ numerator = nominal * 1000;
13
+ denominator = 1001;
14
+ } else {
15
+ numerator = Math.round(fps * 1e6);
16
+ denominator = 1e6;
17
+ }
18
+ if (!Number.isSafeInteger(numerator) || numerator <= 0) throw invalidOption('--fps', 'Frame rate cannot be represented at micro-fps precision.');
19
+ let a = numerator, b = denominator;
20
+ while (b) [a, b] = [b, a % b];
21
+ numerator /= a; denominator /= a;
22
+ if (numerator > 2147483647) throw invalidOption('--fps', 'Frame rate exceeds FFmpeg rational precision.');
23
+ return {
24
+ numerator, denominator, argument: `${numerator}/${denominator}`,
25
+ fps: numerator / denominator,
26
+ seconds: frames => frames * denominator / numerator,
27
+ };
28
+ }
@@ -1,54 +1,54 @@
1
- import path from 'node:path';
2
- import os from 'node:os';
3
- import { randomUUID } from 'node:crypto';
4
- import { lstatSync, mkdirSync, openSync, closeSync, copyFileSync, renameSync, unlinkSync } from 'node:fs';
5
- import { userHome } from './platform.mjs';
6
-
7
- const present = file => {
8
- try { lstatSync(file); return true; }
9
- catch (error) { if (error.code === 'ENOENT') return false; throw error; }
10
- };
11
-
12
- // Use the writers' locks, including the config lock introduced in 038.
13
- // Publish only complete copies, and recheck the destination under the lock.
14
- export function migrateUserHome(env = process.env, platform = process.platform, home = os.homedir()) {
15
- const result = { migrated: [], warnings: [] };
16
- if (platform !== 'win32' || env.MULTIPLANE_HOME) return result;
17
- const directory = userHome(env, platform, home);
18
- const previous = path.win32.join(env.APPDATA || path.win32.join(env.USERPROFILE || home, 'AppData', 'Roaming'), 'multiplane');
19
- if (path.win32.resolve(previous).toLowerCase() === path.win32.resolve(directory).toLowerCase()) return result;
20
- for (const [lockName, names] of [['config.json.lock', ['config.json']], ['jobs.lock', ['jobs.jsonl', 'jobs.1.jsonl']]]) {
21
- let handle;
22
- const lock = path.join(directory, lockName);
23
- try {
24
- if (!names.some(name => !present(path.join(directory, name)) && present(path.join(previous, name)))) continue;
25
- mkdirSync(directory, { recursive: true });
26
- const deadline = performance.now() + 10000;
27
- for (;;) {
28
- try { handle = openSync(lock, 'wx'); break; }
29
- catch (error) {
30
- if (error.code !== 'EEXIST' || performance.now() >= deadline) throw error;
31
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
32
- }
33
- }
34
- for (const name of names) {
35
- const target = path.join(directory, name), source = path.join(previous, name);
36
- if (present(target) || !present(source)) continue;
37
- const temporary = target + '.' + randomUUID() + '.tmp';
38
- try {
39
- copyFileSync(source, temporary);
40
- renameSync(temporary, target);
41
- result.migrated.push({ name, source });
42
- } finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
43
- }
44
- } catch {
45
- result.warnings.push({ code: 'home_unavailable', message: 'User directory migration could not be completed.' });
46
- } finally {
47
- if (handle !== undefined) {
48
- closeSync(handle);
49
- try { unlinkSync(lock); } catch { result.warnings.push({ code: 'home_unavailable', message: 'User directory migration lock could not be removed.' }); }
50
- }
51
- }
52
- }
53
- return result;
54
- }
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { lstatSync, mkdirSync, openSync, closeSync, copyFileSync, renameSync, unlinkSync } from 'node:fs';
5
+ import { userHome } from './platform.mjs';
6
+
7
+ const present = file => {
8
+ try { lstatSync(file); return true; }
9
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
10
+ };
11
+
12
+ // Use the writers' locks, including the config lock introduced in 038.
13
+ // Publish only complete copies, and recheck the destination under the lock.
14
+ export function migrateUserHome(env = process.env, platform = process.platform, home = os.homedir()) {
15
+ const result = { migrated: [], warnings: [] };
16
+ if (platform !== 'win32' || env.MULTIPLANE_HOME) return result;
17
+ const directory = userHome(env, platform, home);
18
+ const previous = path.win32.join(env.APPDATA || path.win32.join(env.USERPROFILE || home, 'AppData', 'Roaming'), 'multiplane');
19
+ if (path.win32.resolve(previous).toLowerCase() === path.win32.resolve(directory).toLowerCase()) return result;
20
+ for (const [lockName, names] of [['config.json.lock', ['config.json']], ['jobs.lock', ['jobs.jsonl', 'jobs.1.jsonl']]]) {
21
+ let handle;
22
+ const lock = path.join(directory, lockName);
23
+ try {
24
+ if (!names.some(name => !present(path.join(directory, name)) && present(path.join(previous, name)))) continue;
25
+ mkdirSync(directory, { recursive: true });
26
+ const deadline = performance.now() + 10000;
27
+ for (;;) {
28
+ try { handle = openSync(lock, 'wx'); break; }
29
+ catch (error) {
30
+ if (error.code !== 'EEXIST' || performance.now() >= deadline) throw error;
31
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
32
+ }
33
+ }
34
+ for (const name of names) {
35
+ const target = path.join(directory, name), source = path.join(previous, name);
36
+ if (present(target) || !present(source)) continue;
37
+ const temporary = target + '.' + randomUUID() + '.tmp';
38
+ try {
39
+ copyFileSync(source, temporary);
40
+ renameSync(temporary, target);
41
+ result.migrated.push({ name, source });
42
+ } finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
43
+ }
44
+ } catch {
45
+ result.warnings.push({ code: 'home_unavailable', message: 'User directory migration could not be completed.' });
46
+ } finally {
47
+ if (handle !== undefined) {
48
+ closeSync(handle);
49
+ try { unlinkSync(lock); } catch { result.warnings.push({ code: 'home_unavailable', message: 'User directory migration lock could not be removed.' }); }
50
+ }
51
+ }
52
+ }
53
+ return result;
54
+ }
package/src/hosted.mjs CHANGED
@@ -28,7 +28,7 @@ export async function hostedAvailable(command, options, env = process.env) {
28
28
  }
29
29
 
30
30
  export function hostedEnvironment(env) {
31
- const allowed = /^(PATH|MULTIPLANE_.*|ELECTRON_RUN_AS_NODE|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|TEMP|TMP|TMPDIR|HOME|USERPROFILE|APPDATA|LOCALAPPDATA|SystemRoot|ComSpec|PATHEXT|LANG|LC_.*)$/i;
31
+ const allowed = /^(PATH|MULTIPLANE_.*|ELECTRON_RUN_AS_NODE|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|TEMP|TMP|TMPDIR|HOME|USERPROFILE|APPDATA|LOCALAPPDATA|SystemRoot|ComSpec|PATHEXT|LANG|LC_.*)$/i;
32
32
  const secret = /TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|BEARER|COOKIE|SESSION|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY/i;
33
33
  return Object.fromEntries(Object.entries(env).filter(([key, value]) => {
34
34
  if (value === undefined || !allowed.test(key) || secret.test(key)) return false;