@deckbuilderkit/platform 0.0.0-stage → 0.1.1

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,6 +1,21 @@
1
1
  {
2
2
  "name": "@deckbuilderkit/platform",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/adrianogpena/deckbuilder.git",
8
+ "directory": "packages/platform"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ }
21
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { win32 } from 'node:path';
3
+
4
+ export interface FindBrowserOptions {
5
+ platform?: NodeJS.Platform;
6
+ env?: Record<string, string | undefined>;
7
+ exists?: (path: string) => boolean;
8
+ which?: (cmd: string) => string | null;
9
+ }
10
+
11
+ const WINDOWS_BROWSERS = ['Microsoft\\Edge\\Application\\msedge.exe', 'Google\\Chrome\\Application\\chrome.exe'];
12
+ const WINDOWS_ROOTS = ['ProgramFiles(x86)', 'ProgramFiles', 'LOCALAPPDATA'];
13
+ const LINUX_BROWSERS = ['microsoft-edge', 'google-chrome', 'chromium', 'chromium-browser'];
14
+ const MAC_BROWSERS = [
15
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
16
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
17
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
18
+ ];
19
+
20
+ /** The Chromium browser that can open an app window (`--app=<url>`): Edge first, then Chrome. Null when none is installed. */
21
+ export function findAppBrowser(opts: FindBrowserOptions = {}): string | null {
22
+ const platform = opts.platform ?? process.platform;
23
+ const exists = opts.exists ?? existsSync;
24
+ if (platform === 'win32') {
25
+ const env = opts.env ?? process.env;
26
+ for (const browser of WINDOWS_BROWSERS) {
27
+ for (const root of WINDOWS_ROOTS) {
28
+ const dir = env[root];
29
+ if (!dir) continue;
30
+ const path = win32.join(dir, browser);
31
+ if (exists(path)) return path;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ if (platform === 'darwin') return MAC_BROWSERS.find(exists) ?? null;
37
+ const which = opts.which ?? ((cmd: string) => Bun.which(cmd));
38
+ for (const cmd of LINUX_BROWSERS) {
39
+ const path = which(cmd);
40
+ if (path) return path;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** Command line for an app window. Its own `profileDir` gives the app a browser process apart from the user's browser. */
46
+ export function appBrowserArgv(browser: string, url: string, profileDir: string, size: { width: number; height: number }): string[] {
47
+ return [
48
+ browser,
49
+ `--app=${url}`,
50
+ `--user-data-dir=${profileDir}`,
51
+ '--no-first-run',
52
+ '--no-default-browser-check',
53
+ `--window-size=${size.width},${size.height}`,
54
+ ];
55
+ }
package/src/dialogs.ts ADDED
@@ -0,0 +1,296 @@
1
+ import { PlatformError } from './errors.ts';
2
+ import { asciiJson, spawnPowerShell } from './win/powershell.ts';
3
+
4
+ export interface DialogFilter {
5
+ name: string;
6
+ /** Globs such as `*.md`. */
7
+ patterns: string[];
8
+ }
9
+
10
+ export interface FolderDialogOptions {
11
+ title?: string;
12
+ /** Folder the dialog starts in. */
13
+ start?: string;
14
+ }
15
+
16
+ export interface FileDialogOptions extends FolderDialogOptions {
17
+ filters?: DialogFilter[];
18
+ }
19
+
20
+ export interface SaveDialogOptions {
21
+ title?: string;
22
+ suggestedName?: string;
23
+ filters?: DialogFilter[];
24
+ }
25
+
26
+ export type DialogKind = 'folder' | 'file' | 'save';
27
+
28
+ interface DialogProcess {
29
+ stdin: { write(data: string): unknown; end(): unknown };
30
+ stdout: ReadableStream<Uint8Array>;
31
+ stderr: ReadableStream<Uint8Array>;
32
+ exited: Promise<number>;
33
+ }
34
+
35
+ export interface DialogDeps {
36
+ spawn: (script: string, opts: { sta: true; stdin: 'pipe'; stdout: 'pipe' }) => DialogProcess;
37
+ }
38
+
39
+ const defaultDeps: DialogDeps = { spawn: spawnPowerShell };
40
+
41
+ /** Opens the native folder picker; resolves the chosen absolute path, or null when cancelled. */
42
+ export const pickFolder = (opts: FolderDialogOptions = {}, deps = defaultDeps) => openDialog('folder', opts, deps);
43
+
44
+ /** Opens the native "Open file" dialog; resolves the chosen absolute path, or null when cancelled. */
45
+ export const pickFile = (opts: FileDialogOptions = {}, deps = defaultDeps) => openDialog('file', opts, deps);
46
+
47
+ /** Opens the native "Save as" dialog (asks before overwriting); resolves the absolute path, or null when cancelled. */
48
+ export const saveFileAs = (opts: SaveDialogOptions = {}, deps = defaultDeps) => openDialog('save', opts, deps);
49
+
50
+ interface Request {
51
+ title: string;
52
+ start: string;
53
+ name: string;
54
+ filters: DialogFilter[];
55
+ }
56
+
57
+ const str = (v: unknown, max = 1000) => (typeof v === 'string' ? v.slice(0, max) : '');
58
+
59
+ function request(opts: FileDialogOptions & SaveDialogOptions): Request {
60
+ const filters = (Array.isArray(opts.filters) ? opts.filters : [])
61
+ .slice(0, 20)
62
+ .map((f) => ({
63
+ name: str(f?.name, 100),
64
+ patterns: (Array.isArray(f?.patterns) ? f.patterns : []).map((p) => str(p, 100).replace(/[;|]/g, '')).filter(Boolean).slice(0, 20),
65
+ }))
66
+ .filter((f) => f.patterns.length);
67
+ return { title: str(opts.title, 200), start: str(opts.start), name: str(opts.suggestedName, 260), filters };
68
+ }
69
+
70
+ let busy = false;
71
+
72
+ async function openDialog(kind: DialogKind, opts: FileDialogOptions & SaveDialogOptions, deps: DialogDeps): Promise<string | null> {
73
+ if (busy) throw new PlatformError('dialogBusy', 'A dialog is already open.');
74
+ busy = true;
75
+ try {
76
+ const req = request(opts);
77
+ if (deps !== defaultDeps || process.platform === 'win32') return await windowsDialog(kind, req, deps);
78
+ if (process.platform === 'darwin') return await macDialog(kind, req);
79
+ return await zenityDialog(kind, req);
80
+ } finally {
81
+ busy = false;
82
+ }
83
+ }
84
+
85
+ // --- Windows ---------------------------------------------------------------------------------
86
+
87
+ // The COM IFileOpenDialog / IFileSaveDialog (the Vista+ dialogs). Only IFileDialog's own methods
88
+ // are called, so both coclasses are used through that one interface. Writes the result as ASCII
89
+ // JSON itself: Windows PowerShell's ConvertTo-Json leaves non-ASCII to the console code page.
90
+ const DIALOG_CS = `
91
+ using System;
92
+ using System.Runtime.InteropServices;
93
+ using System.Text;
94
+
95
+ namespace DeckBuilder {
96
+ public static class FileDialog {
97
+ public const uint FOS_OVERWRITEPROMPT = 0x2;
98
+ public const uint FOS_PICKFOLDERS = 0x20;
99
+ public const uint FOS_FORCEFILESYSTEM = 0x40;
100
+ public const uint FOS_PATHMUSTEXIST = 0x800;
101
+ public const uint FOS_FILEMUSTEXIST = 0x1000;
102
+ const uint SIGDN_FILESYSPATH = 0x80058000;
103
+ const int ERROR_CANCELLED = unchecked((int)0x800704C7);
104
+
105
+ [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class FileOpenDialogCo {}
106
+ [ComImport, Guid("C0B4E2F3-BA21-4773-8DBA-335EC946EB8B")] class FileSaveDialogCo {}
107
+
108
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
109
+ struct FilterSpec {
110
+ [MarshalAs(UnmanagedType.LPWStr)] public string Name;
111
+ [MarshalAs(UnmanagedType.LPWStr)] public string Spec;
112
+ }
113
+
114
+ [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
115
+ interface IShellItem {
116
+ void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
117
+ void GetParent(out IShellItem ppsi);
118
+ void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
119
+ void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
120
+ void Compare(IShellItem psi, uint hint, out int piOrder);
121
+ }
122
+
123
+ [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
124
+ interface IFileDialog {
125
+ [PreserveSig] int Show(IntPtr parent);
126
+ void SetFileTypes(uint cFileTypes, [MarshalAs(UnmanagedType.LPArray)] FilterSpec[] rgFilterSpec);
127
+ void SetFileTypeIndex(uint iFileType);
128
+ void GetFileTypeIndex(out uint piFileType);
129
+ void Advise(IntPtr pfde, out uint pdwCookie);
130
+ void Unadvise(uint dwCookie);
131
+ void SetOptions(uint fos);
132
+ void GetOptions(out uint pfos);
133
+ void SetDefaultFolder(IShellItem psi);
134
+ void SetFolder(IShellItem psi);
135
+ void GetFolder(out IShellItem ppsi);
136
+ void GetCurrentSelection(out IShellItem ppsi);
137
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
138
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
139
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
140
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
141
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
142
+ void GetResult(out IShellItem ppsi);
143
+ void AddPlace(IShellItem psi, int fdap);
144
+ void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
145
+ void Close(int hr);
146
+ void SetClientGuid(ref Guid guid);
147
+ void ClearClientData();
148
+ void SetFilter(IntPtr pFilter);
149
+ }
150
+
151
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
152
+ static extern void SHCreateItemFromParsingName(string pszPath, IntPtr pbc, [MarshalAs(UnmanagedType.LPStruct)] Guid riid, out IShellItem ppv);
153
+
154
+ [DllImport("user32.dll")]
155
+ static extern IntPtr GetForegroundWindow();
156
+
157
+ public static string Run(bool save, uint options, string title, string start, string name, string[] filterNames, string[] filterSpecs) {
158
+ IFileDialog dialog = save ? (IFileDialog)new FileSaveDialogCo() : (IFileDialog)new FileOpenDialogCo();
159
+ try {
160
+ uint current;
161
+ dialog.GetOptions(out current);
162
+ dialog.SetOptions(current | options | FOS_FORCEFILESYSTEM);
163
+ if (!String.IsNullOrEmpty(title)) dialog.SetTitle(title);
164
+ if (!String.IsNullOrEmpty(name)) dialog.SetFileName(name);
165
+ if (!String.IsNullOrEmpty(start)) {
166
+ try {
167
+ IShellItem folder;
168
+ SHCreateItemFromParsingName(start, IntPtr.Zero, typeof(IShellItem).GUID, out folder);
169
+ dialog.SetFolder(folder);
170
+ } catch (Exception) {
171
+ // a start folder that doesn't exist: the dialog opens where it would anyway
172
+ }
173
+ }
174
+ if (filterNames.Length > 0) {
175
+ FilterSpec[] specs = new FilterSpec[filterNames.Length];
176
+ for (int i = 0; i < specs.Length; i++) { specs[i].Name = filterNames[i]; specs[i].Spec = filterSpecs[i]; }
177
+ dialog.SetFileTypes((uint)specs.Length, specs);
178
+ }
179
+ int hr = dialog.Show(GetForegroundWindow());
180
+ if (hr == ERROR_CANCELLED) return Json(null);
181
+ Marshal.ThrowExceptionForHR(hr);
182
+ IShellItem result;
183
+ dialog.GetResult(out result);
184
+ string path;
185
+ result.GetDisplayName(SIGDN_FILESYSPATH, out path);
186
+ return Json(path);
187
+ } finally {
188
+ Marshal.ReleaseComObject(dialog);
189
+ }
190
+ }
191
+
192
+ static string Json(string path) {
193
+ if (path == null) return "{\\"path\\":null}";
194
+ StringBuilder sb = new StringBuilder("{\\"path\\":\\"");
195
+ foreach (char c in path) {
196
+ if (c == '"' || c == '\\\\') sb.Append('\\\\').Append(c);
197
+ else if (c < 0x20 || c > 0x7e) sb.Append("\\\\u").Append(((int)c).ToString("x4"));
198
+ else sb.Append(c);
199
+ }
200
+ return sb.Append("\\"}").ToString();
201
+ }
202
+ }
203
+ }
204
+ `;
205
+
206
+ const FLAGS: Record<DialogKind, string> = {
207
+ folder: '[DeckBuilder.FileDialog]::FOS_PICKFOLDERS -bor [DeckBuilder.FileDialog]::FOS_PATHMUSTEXIST',
208
+ file: '[DeckBuilder.FileDialog]::FOS_FILEMUSTEXIST -bor [DeckBuilder.FileDialog]::FOS_PATHMUSTEXIST',
209
+ save: '[DeckBuilder.FileDialog]::FOS_OVERWRITEPROMPT -bor [DeckBuilder.FileDialog]::FOS_PATHMUSTEXIST',
210
+ };
211
+
212
+ /**
213
+ * The PowerShell script for one dialog kind. It holds no caller data: the options arrive as JSON
214
+ * on stdin, so a title can't inject script.
215
+ */
216
+ export function dialogScript(kind: DialogKind): string {
217
+ return `
218
+ $ErrorActionPreference = 'Stop'
219
+ $ProgressPreference = 'SilentlyContinue'
220
+ $o = [Console]::In.ReadToEnd() | ConvertFrom-Json
221
+ Add-Type -TypeDefinition @'
222
+ ${DIALOG_CS}
223
+ '@
224
+ $names = @(); $specs = @()
225
+ foreach ($f in @($o.filters)) { if ($f) { $names += [string]$f.name; $specs += (@($f.patterns) -join ';') } }
226
+ $json = [DeckBuilder.FileDialog]::Run($${kind === 'save'}, (${FLAGS[kind]}), [string]$o.title, [string]$o.start, [string]$o.name, [string[]]$names, [string[]]$specs)
227
+ [Console]::Out.Write($json)
228
+ `;
229
+ }
230
+
231
+ async function windowsDialog(kind: DialogKind, req: Request, deps: DialogDeps): Promise<string | null> {
232
+ const proc = deps.spawn(dialogScript(kind), { sta: true, stdin: 'pipe', stdout: 'pipe' });
233
+ proc.stdin.write(asciiJson(req));
234
+ proc.stdin.end();
235
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
236
+ if (code !== 0) throw new PlatformError('dialogFailed', 'The dialog could not be opened.', stderr.trim());
237
+ try {
238
+ const { path } = JSON.parse(stdout) as { path: unknown };
239
+ if (path === null || typeof path === 'string') return path || null;
240
+ } catch {
241
+ // reported below
242
+ }
243
+ throw new PlatformError('dialogFailed', 'The dialog could not be opened.', stdout.trim());
244
+ }
245
+
246
+ // --- Linux and macOS (best-effort, untested) -------------------------------------------------
247
+
248
+ async function runDialog(argv: string[]): Promise<string | null> {
249
+ let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
250
+ try {
251
+ proc = Bun.spawn(argv, { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
252
+ } catch (err) {
253
+ throw new PlatformError('dialogFailed', 'The dialog could not be opened.', (err as Error).message);
254
+ }
255
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
256
+ if (code === 1) return null;
257
+ if (code !== 0) throw new PlatformError('dialogFailed', 'The dialog could not be opened.', stderr.trim());
258
+ return stdout.replace(/\r?\n$/, '') || null;
259
+ }
260
+
261
+ function zenityDialog(kind: DialogKind, req: Request): Promise<string | null> {
262
+ const argv = ['zenity', '--file-selection'];
263
+ if (req.title) argv.push(`--title=${req.title}`);
264
+ if (kind === 'folder') argv.push('--directory');
265
+ if (kind === 'save') argv.push('--save', '--confirm-overwrite');
266
+ const start = kind === 'save' ? req.name : req.start && req.start.replace(/\/?$/, '/');
267
+ if (start) argv.push(`--filename=${start}`);
268
+ for (const f of req.filters) argv.push(`--file-filter=${f.name || f.patterns.join(' ')} | ${f.patterns.join(' ')}`);
269
+ return runDialog(argv);
270
+ }
271
+
272
+ // The AppleScript text is fixed per option set; the values arrive as `argv` of its run handler.
273
+ function macDialog(kind: DialogKind, req: Request): Promise<string | null> {
274
+ const args: string[] = [];
275
+ const clauses: string[] = [];
276
+ const arg = (value: string) => {
277
+ args.push(value);
278
+ return `(item ${args.length} of argv)`;
279
+ };
280
+ if (req.title) clauses.push(`with prompt ${arg(req.title)}`);
281
+ if (kind === 'save' && req.name) clauses.push(`default name ${arg(req.name)}`);
282
+ if (kind !== 'save' && req.start) clauses.push(`default location (POSIX file ${arg(req.start)})`);
283
+ const command = { folder: 'choose folder', file: 'choose file', save: 'choose file name' }[kind];
284
+ const lines = ['on run argv', `POSIX path of (${command} ${clauses.join(' ')})`, 'end run'];
285
+ return runDialog(['osascript', ...lines.flatMap((line) => ['-e', line]), '--', ...args]);
286
+ }
287
+
288
+ // `bun packages/platform/src/dialogs.ts --selftest`: opens the folder picker, then "Save as";
289
+ // prints each result (null = cancelled).
290
+ if (import.meta.main && process.argv.includes('--selftest')) {
291
+ console.log('pickFolder:', await pickFolder({ title: 'DeckBuilder self-test: pick a folder "ção"' }));
292
+ console.log(
293
+ 'saveFileAs:',
294
+ await saveFileAs({ title: 'DeckBuilder self-test: save as', suggestedName: 'notes.md', filters: [{ name: 'Markdown', patterns: ['*.md'] }] }),
295
+ );
296
+ }
package/src/editors.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { isAbsolute, win32 } from 'node:path';
4
+ import { PlatformError } from './errors.ts';
5
+ import { launch } from './launch.ts';
6
+
7
+ export type EditorId = 'vscode' | 'intellij';
8
+
9
+ export const EDITOR_NAMES: Record<EditorId, string> = { vscode: 'VS Code', intellij: 'IntelliJ IDEA' };
10
+
11
+ export interface EditorDeps {
12
+ platform: NodeJS.Platform;
13
+ env: Record<string, string | undefined>;
14
+ which(cmd: string): string | null;
15
+ exists(path: string): boolean;
16
+ readText(path: string): Promise<string | null>;
17
+ listDir(path: string): Promise<string[]>;
18
+ }
19
+
20
+ const defaultDeps: EditorDeps = {
21
+ platform: process.platform,
22
+ env: process.env,
23
+ which: (cmd) => Bun.which(cmd),
24
+ exists: (path) => existsSync(path),
25
+ readText: (path) => readFile(path, 'utf8').catch(() => null),
26
+ listDir: (path) => readdir(path).catch(() => []),
27
+ };
28
+
29
+ const versionOf = (name: string) => (name.match(/(\d+(?:\.\d+)*)$/)?.[1] ?? '').split('.').filter(Boolean).map(Number);
30
+
31
+ function compareVersions(a: string, b: string): number {
32
+ const [x, y] = [versionOf(a), versionOf(b)];
33
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
34
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
35
+ if (d) return d;
36
+ }
37
+ return a.localeCompare(b);
38
+ }
39
+
40
+ async function findOnWindows(id: EditorId, deps: EditorDeps): Promise<string | null> {
41
+ const { env, exists } = deps;
42
+ const local = env.LOCALAPPDATA;
43
+ const programs = env.ProgramFiles;
44
+ if (id === 'vscode') {
45
+ const code = deps.which('code');
46
+ if (code && /\x5cbin\x5ccode\.cmd$/i.test(code)) {
47
+ const exe = win32.join(win32.dirname(code), '..', 'Code.exe');
48
+ if (exists(exe)) return exe;
49
+ }
50
+ const candidates = [
51
+ local && win32.join(local, 'Programs', 'Microsoft VS Code', 'Code.exe'),
52
+ programs && win32.join(programs, 'Microsoft VS Code', 'Code.exe'),
53
+ ];
54
+ return candidates.find((c): c is string => !!c && exists(c)) ?? null;
55
+ }
56
+ const script = deps.which('idea') ?? (local ? win32.join(local, 'JetBrains', 'Toolbox', 'scripts', 'idea.cmd') : null);
57
+ const text = script ? await deps.readText(script) : null;
58
+ const fromScript = text?.match(/"([^"]+\x5cidea64\.exe)"/i)?.[1];
59
+ if (fromScript && exists(fromScript)) return fromScript;
60
+ for (const base of [local && win32.join(local, 'Programs'), programs && win32.join(programs, 'JetBrains')]) {
61
+ if (!base) continue;
62
+ const folders = (await deps.listDir(base)).filter((name) => name.startsWith('IntelliJ IDEA')).sort(compareVersions);
63
+ const newest = folders.at(-1);
64
+ if (!newest) continue;
65
+ const exe = win32.join(base, newest, 'bin', 'idea64.exe');
66
+ if (exists(exe)) return exe;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** The program that opens `id`, or null when it isn't installed (best-effort off Windows). */
72
+ export async function findEditor(id: EditorId, deps: EditorDeps = defaultDeps): Promise<string | null> {
73
+ if (deps.platform === 'win32') return findOnWindows(id, deps);
74
+ return deps.which(id === 'vscode' ? 'code' : 'idea');
75
+ }
76
+
77
+ export interface OpenInEditorOptions {
78
+ /** The project folder (absolute). */
79
+ folder: string;
80
+ /** A file inside it (absolute). */
81
+ file?: string;
82
+ line?: number;
83
+ }
84
+
85
+ /** The argv that opens the project (and a file) in the editor; no shell is involved. */
86
+ export function editorArgv(id: EditorId, exe: string, opts: OpenInEditorOptions): string[] {
87
+ const { folder, file, line } = opts;
88
+ if (id === 'vscode') return file ? [exe, folder, '-g', `${file}:${line ?? 1}`] : [exe, folder];
89
+ return file ? [exe, '--line', String(line ?? 1), file] : [exe, folder];
90
+ }
91
+
92
+ /**
93
+ * Opens a project in an editor, detached. Rejects with PlatformError 'editorNotExe' unless `exe`
94
+ * is an existing absolute path (on Windows a `.exe`): `code.cmd` and `idea.cmd` would go through
95
+ * cmd.exe, which interprets `&`, `%` and `^` in a project path.
96
+ */
97
+ export async function openInEditor(
98
+ id: EditorId,
99
+ exe: string,
100
+ opts: OpenInEditorOptions,
101
+ deps: Pick<EditorDeps, 'platform' | 'exists'> = defaultDeps,
102
+ ): Promise<void> {
103
+ const win = deps.platform === 'win32';
104
+ const valid = win ? win32.isAbsolute(exe) && /\.exe$/i.test(exe) : isAbsolute(exe);
105
+ if (!valid || !deps.exists(exe)) throw new PlatformError('editorNotExe', 'The editor program must be an existing .exe file.', exe);
106
+ launch(editorArgv(id, exe, opts));
107
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * An expected platform failure. platform has no server-core dep, so server code maps it with
3
+ * `fromPlatform` (server-mods) to an AppError: `code` picks the UI text (`error.<code>`),
4
+ * `detail` carries raw tool output.
5
+ */
6
+ export class PlatformError extends Error {
7
+ constructor(
8
+ readonly code: string,
9
+ message: string,
10
+ readonly detail?: string,
11
+ ) {
12
+ super(message);
13
+ this.name = 'PlatformError';
14
+ }
15
+ }
package/src/exe.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join } from 'node:path';
3
+
4
+ /** True inside a `bun build --compile` exe, false when running from source with bun. */
5
+ export function isCompiledExe(execPath = process.execPath): boolean {
6
+ return !/^(bun|node)(\.exe)?$/i.test(basename(execPath));
7
+ }
8
+
9
+ /**
10
+ * Files next to the exe during a self-update: `fresh` is the downloaded release, `old` the
11
+ * running exe moved aside (Windows allows renaming a running exe, not replacing it).
12
+ */
13
+ export function updatePaths(execPath: string): { fresh: string; old: string } {
14
+ const ext = extname(execPath);
15
+ const stem = join(dirname(execPath), basename(execPath, ext));
16
+ return { fresh: `${stem}.new${ext}`, old: `${stem}.old${ext}` };
17
+ }
18
+
19
+ /**
20
+ * Run by the new exe (`--after-update`) before anything else: deleting the old exe fails while
21
+ * the old process still runs, so success means it has exited. Gives up after `timeoutMs` (false),
22
+ * leaving the file for the next update to remove.
23
+ */
24
+ export async function removeOldExe(
25
+ execPath: string,
26
+ opts: { remove?: (path: string) => Promise<void>; timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
27
+ ): Promise<boolean> {
28
+ const remove = opts.remove ?? ((path: string) => rm(path, { force: true }));
29
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
30
+ const pollMs = opts.pollMs ?? 250;
31
+ const tries = Math.max(1, Math.ceil((opts.timeoutMs ?? 30_000) / pollMs));
32
+ const { old } = updatePaths(execPath);
33
+ for (let i = 0; i < tries; i++) {
34
+ try {
35
+ await remove(old);
36
+ return true;
37
+ } catch {
38
+ await sleep(pollMs);
39
+ }
40
+ }
41
+ return false;
42
+ }
package/src/files.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { dirname, extname } from 'node:path';
2
+ import { PlatformError } from './errors.ts';
3
+ import { launch } from './launch.ts';
4
+
5
+ // Extensions whose default app shows the file. An allowlist: a denylist misses every script type
6
+ // another tool registers (`.py` → py.exe). `.svg`/`.html` open in a browser and run script; macro
7
+ // formats (`.docm`, `.xlsm`, `.pptm`) are left out too.
8
+ const OPENABLE = new Set(
9
+ [
10
+ '.txt .md .markdown .log .csv .tsv .json .xml .yaml .yml .toml .ini .sql',
11
+ '.pdf .docx .xlsx .pptx .odt .ods .odp',
12
+ '.png .jpg .jpeg .gif .webp .bmp .ico .avif .tif .tiff',
13
+ '.mp3 .wav .ogg .flac .m4a .mp4 .webm .mov .mkv .avi',
14
+ '.zip',
15
+ ].join(' ').split(' '),
16
+ );
17
+
18
+ /** True when `path` is a document, image, media file or zip that `openWithDefault` may open. */
19
+ export function isOpenable(path: string): boolean {
20
+ return OPENABLE.has(extname(path).toLowerCase());
21
+ }
22
+
23
+ /**
24
+ * The command that opens `path` with its default app. On Windows the path is quoted and passed
25
+ * verbatim: explorer.exe splits an unquoted command line at commas, so `run.cmd,a.txt` ran `run.cmd`.
26
+ */
27
+ export function openArgv(path: string, platform: NodeJS.Platform = process.platform): { argv: string[]; verbatim: boolean } {
28
+ // Windows paths can't contain `"`, so quoting is safe.
29
+ if (platform === 'win32') return { argv: ['explorer.exe', `"${path}"`], verbatim: true };
30
+ return { argv: [platform === 'darwin' ? 'open' : 'xdg-open', path], verbatim: false };
31
+ }
32
+
33
+ /** Shows the absolute `path` selected in the OS file manager (Linux: opens its folder). */
34
+ export async function revealInFolder(path: string): Promise<void> {
35
+ // explorer.exe parses its own command line; Windows paths can't contain `"`, so quoting is safe.
36
+ if (process.platform === 'win32') launch(['explorer.exe', `/select,"${path}"`], true);
37
+ else if (process.platform === 'darwin') launch(['open', '-R', path]);
38
+ else launch(['xdg-open', dirname(path)]);
39
+ }
40
+
41
+ /**
42
+ * Opens the absolute `path` with the OS default app. Rejects with PlatformError 'openRefused'
43
+ * unless `isOpenable(path)`: the app shows files, it never runs them.
44
+ */
45
+ export async function openWithDefault(path: string): Promise<void> {
46
+ if (!isOpenable(path)) throw new PlatformError('openRefused', 'This file type is not opened from the app.', path);
47
+ const { argv, verbatim } = openArgv(path);
48
+ launch(argv, verbatim);
49
+ }
package/src/home.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+
4
+ /** App data folder `~/.<id>`: outside the install folder, so data survives reinstalls and updates. */
5
+ export function appHome(id: string): string {
6
+ return join(homedir(), `.${id}`);
7
+ }