@flighthq/shell 0.5.0-next.1944.cd6b6e7 → 0.5.1-edge.638.d4dbd4e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @flighthq/shell
2
2
 
3
- OS shell integration over a swappable web/native backend: open external URLs and paths, reveal items in the file manager, move to trash, beep
3
+ OS shell commands over six explicit Host capability slots with required external URL policy
4
4
 
5
5
  ## Install
6
6
 
@@ -13,5 +13,5 @@ Import the supported application-facing API from `@flighthq/shell`. The `@flight
13
13
  This package is part of the locked-version Flight SDK graph. Applications may instead install and import `@flighthq/sdk` when package-level tree shaking is sufficient.
14
14
 
15
15
  - [Flight project](https://github.com/flighthq/flight)
16
- - [Source for @flighthq/shell@0.5.0-next.1944.cd6b6e7](https://github.com/flighthq/flight/tree/cd6b6e71eb472b3a4249830a7743333f019fa7b7/packages/shell)
17
- - [License](https://github.com/flighthq/flight/blob/cd6b6e71eb472b3a4249830a7743333f019fa7b7/LICENSE.md)
16
+ - [Source for @flighthq/shell@0.5.1-edge.638.d4dbd4e](https://github.com/flighthq/flight/tree/d4dbd4e347dc8be10b180c85a766ab50333dcb51/packages/shell)
17
+ - [License](https://github.com/flighthq/flight/blob/d4dbd4e347dc8be10b180c85a766ab50333dcb51/LICENSE.md)
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { explainShellBackend, isShellUrlAllowed, moveItemToTrash, moveItemsToTrash, openShellExternalUrl, openShellPath, openShellPathResult, readShellShortcutLink, setShellUrlSchemeAllowlist, shellBeep, showItemInFolder, writeShellShortcutLink, } from './contract';
1
+ export { isShellUrlAllowed, moveShellItemToTrash, moveShellItemsToTrash, openShellExternalUrl, openShellPath, readShellShortcutLink, revealShellPath, shellBeep, writeShellShortcutLink, } from './contract';
2
2
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,SAAS,EACT,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,EACpB,aAAa,EACb,qBAAqB,EACrB,eAAe,EACf,SAAS,EACT,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { explainShellBackend, isShellUrlAllowed, moveItemToTrash, moveItemsToTrash, openShellExternalUrl, openShellPath, openShellPathResult, readShellShortcutLink, setShellUrlSchemeAllowlist, shellBeep, showItemInFolder, writeShellShortcutLink, } from './contract';
1
+ export { isShellUrlAllowed, moveShellItemToTrash, moveShellItemsToTrash, openShellExternalUrl, openShellPath, readShellShortcutLink, revealShellPath, shellBeep, writeShellShortcutLink, } from './contract';
2
2
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,SAAS,EACT,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,EACpB,aAAa,EACb,qBAAqB,EACrB,eAAe,EACf,SAAS,EACT,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
package/dist/shell.d.ts CHANGED
@@ -1,20 +1,11 @@
1
- import type { BackendExplanation } from '@flighthq/types/contract';
2
- import type { ShellBackend, ShellOpenExternalOptions, ShellOpenPathOptions, ShellShortcutLink, ShellShortcutWriteOperation } from '@flighthq/types/contract';
3
- export declare function explainShellBackend(): BackendExplanation;
4
- export declare function getShellBackend(): ShellBackend;
5
- export declare function installShellHostBackend(backend: ShellBackend): void;
6
- export declare function isShellUrlAllowed(url: string): boolean;
7
- export declare function moveItemsToTrash(paths: readonly string[]): Promise<readonly boolean[]>;
8
- export declare function moveItemToTrash(path: string): Promise<boolean>;
9
- export declare function observeShellHostResult(operation: string, succeeded: boolean): void;
10
- export declare function openShellExternalUrl(url: string, options?: Readonly<ShellOpenExternalOptions>): Promise<boolean>;
11
- export declare function openShellPath(path: string, options?: Readonly<ShellOpenPathOptions>): Promise<boolean>;
12
- export declare function openShellPathResult(path: string, options?: Readonly<ShellOpenPathOptions>): Promise<string>;
13
- export declare function readShellShortcutLink(shortcutPath: string): Promise<ShellShortcutLink | null>;
14
- export declare function resetShellBackendForTest(): void;
15
- export declare function setShellBackend(backend: ShellBackend | null): void;
16
- export declare function setShellUrlSchemeAllowlist(schemes: readonly string[] | null): void;
17
- export declare function shellBeep(): void;
18
- export declare function showItemInFolder(path: string): Promise<boolean>;
19
- export declare function writeShellShortcutLink(shortcutPath: string, link: Readonly<ShellShortcutLink>, operation?: ShellShortcutWriteOperation): Promise<boolean>;
1
+ import type { HasShellBeep, HasShellExternal, HasShellPathOpen, HasShellPathReveal, HasShellShortcutLink, HasShellTrash, ShellExternalOutcome, ShellExternalUrlPolicy, ShellPathOpenOutcome, ShellPathRevealOutcome, ShellShortcutLink, ShellShortcutLinkReadOutcome, ShellShortcutLinkWriteOutcome, ShellShortcutWriteOperation, ShellTrashOutcome } from '@flighthq/types/contract';
2
+ export declare function isShellUrlAllowed(url: string, policy: Readonly<ShellExternalUrlPolicy>): boolean;
3
+ export declare function moveShellItemsToTrash(host: HasShellTrash, paths: readonly string[]): Promise<readonly ShellTrashOutcome[]>;
4
+ export declare function moveShellItemToTrash(host: HasShellTrash, path: string): Promise<ShellTrashOutcome>;
5
+ export declare function openShellExternalUrl(host: HasShellExternal, url: string, policy: Readonly<ShellExternalUrlPolicy>): Promise<ShellExternalOutcome>;
6
+ export declare function openShellPath(host: HasShellPathOpen, path: string): Promise<ShellPathOpenOutcome>;
7
+ export declare function readShellShortcutLink(host: HasShellShortcutLink, shortcutPath: string): Promise<ShellShortcutLinkReadOutcome>;
8
+ export declare function revealShellPath(host: HasShellPathReveal, path: string): Promise<ShellPathRevealOutcome>;
9
+ export declare function shellBeep(host: HasShellBeep): void;
10
+ export declare function writeShellShortcutLink(host: HasShellShortcutLink, shortcutPath: string, link: Readonly<ShellShortcutLink>, operation: ShellShortcutWriteOperation): Promise<ShellShortcutLinkWriteOutcome>;
20
11
  //# sourceMappingURL=shell.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AACnE,OAAO,KAAK,EACV,YAAY,EACZ,wBAAwB,EACxB,oBAAoB,EACpB,iBAAiB,EACjB,2BAA2B,EAC5B,MAAM,0BAA0B,CAAC;AAElC,wBAAgB,mBAAmB,IAAI,kBAAkB,CAaxD;AAGD,wBAAgB,eAAe,IAAI,YAAY,CAE9C;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAMnE;AAID,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAQtD;AAID,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CAEtF;AAGD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE9D;AAED,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI,CAKlF;AAQD,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,wBAAwB,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAGhH;AAGD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAEtG;AAKD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAE3G;AAID,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAE7F;AAED,wBAAgB,wBAAwB,IAAI,IAAI,CAK/C;AAGD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,IAAI,CAElE;AAMD,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,IAAI,CAElF;AAGD,wBAAgB,SAAS,IAAI,IAAI,CAEhC;AAGD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE/D;AAMD,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,QAAQ,CAAC,iBAAiB,CAAC,EACjC,SAAS,CAAC,EAAE,2BAA2B,GACtC,OAAO,CAAC,OAAO,CAAC,CAElB"}
1
+ {"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,aAAa,EACb,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,EACpB,sBAAsB,EACtB,iBAAiB,EACjB,4BAA4B,EAC5B,6BAA6B,EAC7B,2BAA2B,EAC3B,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAIlC,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,sBAAsB,CAAC,GAAG,OAAO,CAOhG;AAID,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,aAAa,EACnB,KAAK,EAAE,SAAS,MAAM,EAAE,GACvB,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAEvC;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAElG;AAKD,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,gBAAgB,EACtB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,QAAQ,CAAC,sBAAsB,CAAC,GACvC,OAAO,CAAC,oBAAoB,CAAC,CAG/B;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAEjG;AAED,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,oBAAoB,EAC1B,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,4BAA4B,CAAC,CAEvC;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAEvG;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAElD;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,oBAAoB,EAC1B,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,QAAQ,CAAC,iBAAiB,CAAC,EACjC,SAAS,EAAE,2BAA2B,GACrC,OAAO,CAAC,6BAA6B,CAAC,CAExC"}
package/dist/shell.js CHANGED
@@ -1,145 +1,43 @@
1
- export function explainShellBackend() {
2
- if (_custom !== null) {
3
- return { conflict: _hostConflict, layer: 'custom', operation: null, viability: 'unobserved' };
4
- }
5
- if (_host !== null) {
6
- return {
7
- conflict: _hostConflict,
8
- layer: 'host',
9
- operation: _hostObservation !== null ? _hostObservation.operation : null,
10
- viability: _hostObservation !== null ? _hostObservation.viability : 'unobserved',
11
- };
12
- }
13
- return { conflict: false, layer: 'host-not-enabled', operation: null, viability: 'unobserved' };
14
- }
15
- // The active shell backend. Precedence: custom > host > sentinel.
16
- export function getShellBackend() {
17
- return _custom ?? _host ?? _sentinel;
18
- }
19
- export function installShellHostBackend(backend) {
20
- if (_host !== null) {
21
- if (_host !== backend)
22
- _hostConflict = true;
23
- return;
24
- }
25
- _host = backend;
26
- }
27
- // True when url is allowed by the active URL-scheme allowlist. When no allowlist is set, all URLs
28
- // are allowed. Used internally by openShellExternalUrl; also exported for callers that need the check.
29
- export function isShellUrlAllowed(url) {
30
- if (_urlSchemeAllowlist === null)
31
- return true;
1
+ // Pure policy validation used by openShellExternalUrl before any host effect. Callers must supply the
2
+ // policy on every invocation; an empty allowedSchemes array intentionally blocks every URL.
3
+ export function isShellUrlAllowed(url, policy) {
32
4
  try {
33
- const scheme = new URL(url).protocol.replace(/:$/, '');
34
- return _urlSchemeAllowlist.includes(scheme);
5
+ const scheme = new URL(url).protocol.replace(/:$/, '').toLowerCase();
6
+ return policy.allowedSchemes.some((allowed) => allowed.toLowerCase() === scheme);
35
7
  }
36
8
  catch {
37
9
  return false;
38
10
  }
39
11
  }
40
- // Moves a batch of local paths to the OS trash. Returns a per-path boolean array. Empty array on the
41
- // web (native-host only). Single-path convenience: use moveItemToTrash.
42
- export function moveItemsToTrash(paths) {
43
- return getShellBackend().moveItemsToTrash(paths);
44
- }
45
- // Moves a local path to the OS trash. Returns false on the web; native-host capability.
46
- export function moveItemToTrash(path) {
47
- return getShellBackend().moveToTrash(path);
48
- }
49
- export function observeShellHostResult(operation, succeeded) {
50
- _hostObservation = {
51
- operation,
52
- viability: succeeded ? 'available' : 'runtime-api-unavailable',
53
- };
12
+ // Projects the one-path provider operation across the batch. Promise.all starts every operation,
13
+ // awaits every settlement, and returns outcomes in the same order as paths.
14
+ export function moveShellItemsToTrash(host, paths) {
15
+ return Promise.all(paths.map((path) => host.shell.trash.moveToTrash(path)));
54
16
  }
55
- // Opens a URL in the user's default browser / external handler. Returns false when blocked, popup-
56
- // blocked, or the URL scheme is not in the active allowlist (see setShellUrlSchemeAllowlist).
57
- // SECURITY: handing an attacker-controlled URL to the OS default handler is the classic openExternal
58
- // footgun — a non-http(s) scheme can launch a local application or protocol handler. When the url may
59
- // be untrusted, constrain the accepted schemes with setShellUrlSchemeAllowlist (e.g. ['https',
60
- // 'mailto']); isShellUrlAllowed exposes the same check for a caller that wants to gate the URL itself.
61
- export function openShellExternalUrl(url, options) {
62
- if (!isShellUrlAllowed(url))
63
- return Promise.resolve(false);
64
- return getShellBackend().openExternal(url, options);
17
+ export function moveShellItemToTrash(host, path) {
18
+ return host.shell.trash.moveToTrash(path);
65
19
  }
66
- // Opens a local path with its default OS application. Returns false on the web; native-host capability.
67
- export function openShellPath(path, options) {
68
- return getShellBackend().openPath(path, options);
20
+ // Handing a URL to an OS handler can launch a local application or registered protocol. The required
21
+ // per-call policy is validated before dispatch, so a blocked or malformed scheme never reaches the
22
+ // host. There is intentionally no policy default, ambient allowlist, or allow-all path.
23
+ export function openShellExternalUrl(host, url, policy) {
24
+ if (!isShellUrlAllowed(url, policy))
25
+ return Promise.resolve({ reason: 'blocked-scheme' });
26
+ return host.shell.external.open(url);
69
27
  }
70
- // Opens a local path and returns the OS error message, or '' on success. On the web returns
71
- // 'unavailable on web'. Use this when you need the reason a path could not be opened rather than
72
- // just a boolean; openShellPath is the boolean convenience wrapper over this.
73
- export function openShellPathResult(path, options) {
74
- return getShellBackend().openPathResult(path, options);
28
+ export function openShellPath(host, path) {
29
+ return host.shell.pathOpen.open(path);
75
30
  }
76
- // Reads a Windows .lnk shell shortcut. Returns null on non-Windows platforms, on the web, or when
77
- // the shortcut does not exist.
78
- export function readShellShortcutLink(shortcutPath) {
79
- return getShellBackend().readShortcutLink(shortcutPath);
31
+ export function readShellShortcutLink(host, shortcutPath) {
32
+ return host.shell.shortcutLink.read(shortcutPath);
80
33
  }
81
- export function resetShellBackendForTest() {
82
- _custom = null;
83
- _host = null;
84
- _hostConflict = false;
85
- _hostObservation = null;
34
+ export function revealShellPath(host, path) {
35
+ return host.shell.pathReveal.reveal(path);
86
36
  }
87
- // Installs a custom shell backend; pass null to clear the custom override.
88
- export function setShellBackend(backend) {
89
- _custom = backend;
37
+ export function shellBeep(host) {
38
+ host.shell.beep.beep();
90
39
  }
91
- // Sets the URL-scheme allowlist consulted by openShellExternalUrl. Pass null to allow all schemes
92
- // (default behavior). When a non-null list is set, openShellExternalUrl returns false for any URL whose
93
- // scheme is not in the list. Example: setShellUrlSchemeAllowlist(['https', 'mailto']).
94
- // This closes the classic openExternal security footgun with attacker-controlled URLs.
95
- export function setShellUrlSchemeAllowlist(schemes) {
96
- _urlSchemeAllowlist = schemes;
40
+ export function writeShellShortcutLink(host, shortcutPath, link, operation) {
41
+ return host.shell.shortcutLink.write(shortcutPath, link, operation);
97
42
  }
98
- // Emits a system beep. No-op on the web until a native host provides one.
99
- export function shellBeep() {
100
- getShellBackend().beep();
101
- }
102
- // Reveals a local path in the OS file manager. Returns false on the web; native-host capability.
103
- export function showItemInFolder(path) {
104
- return getShellBackend().showItemInFolder(path);
105
- }
106
- // Creates a Windows .lnk shell shortcut at shortcutPath pointing to link. Returns false on
107
- // non-Windows platforms and on the web. operation defaults to 'create'.
108
- // IMPORTANT: URL safety — only use openShellExternalUrl for user-facing link opening; do not pass
109
- // attacker-controlled paths to writeShellShortcutLink without validation.
110
- export function writeShellShortcutLink(shortcutPath, link, operation) {
111
- return getShellBackend().writeShortcutLink(shortcutPath, link, operation);
112
- }
113
- const _sentinel = {
114
- beep() { },
115
- async moveItemsToTrash() {
116
- return [];
117
- },
118
- async moveToTrash() {
119
- return false;
120
- },
121
- async openExternal() {
122
- return false;
123
- },
124
- async openPath() {
125
- return false;
126
- },
127
- async openPathResult() {
128
- return 'unavailable on web';
129
- },
130
- async readShortcutLink() {
131
- return null;
132
- },
133
- async showItemInFolder() {
134
- return false;
135
- },
136
- async writeShortcutLink() {
137
- return false;
138
- },
139
- };
140
- let _custom = null;
141
- let _host = null;
142
- let _hostConflict = false;
143
- let _hostObservation = null;
144
- let _urlSchemeAllowlist = null;
145
43
  //# sourceMappingURL=shell.js.map
package/dist/shell.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"shell.js","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AASA,MAAM,UAAU,mBAAmB;IACjC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;IAChG,CAAC;IACD,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACnB,OAAO;YACL,QAAQ,EAAE,aAAa;YACvB,KAAK,EAAE,MAAM;YACb,SAAS,EAAE,gBAAgB,KAAK,IAAI,CAAC,CAAC,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI;YACxE,SAAS,EAAE,gBAAgB,KAAK,IAAI,CAAC,CAAC,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAC,YAAY;SACjF,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;AAClG,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,eAAe;IAC7B,OAAO,OAAO,IAAI,KAAK,IAAI,SAAS,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,OAAqB;IAC3D,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACnB,IAAI,KAAK,KAAK,OAAO;YAAE,aAAa,GAAG,IAAI,CAAC;QAC5C,OAAO;IACT,CAAC;IACD,KAAK,GAAG,OAAO,CAAC;AAClB,CAAC;AAED,kGAAkG;AAClG,uGAAuG;AACvG,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,IAAI,mBAAmB,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC9C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACvD,OAAO,mBAAmB,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,qGAAqG;AACrG,wEAAwE;AACxE,MAAM,UAAU,gBAAgB,CAAC,KAAwB;IACvD,OAAO,eAAe,EAAE,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC;AACnD,CAAC;AAED,wFAAwF;AACxF,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,OAAO,eAAe,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;AAC7C,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,SAAiB,EAAE,SAAkB;IAC1E,gBAAgB,GAAG;QACjB,SAAS;QACT,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,yBAAyB;KAC/D,CAAC;AACJ,CAAC;AAED,mGAAmG;AACnG,8FAA8F;AAC9F,qGAAqG;AACrG,sGAAsG;AACtG,+FAA+F;AAC/F,uGAAuG;AACvG,MAAM,UAAU,oBAAoB,CAAC,GAAW,EAAE,OAA4C;IAC5F,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3D,OAAO,eAAe,EAAE,CAAC,YAAY,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;AACtD,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,OAAwC;IAClF,OAAO,eAAe,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AACnD,CAAC;AAED,4FAA4F;AAC5F,iGAAiG;AACjG,8EAA8E;AAC9E,MAAM,UAAU,mBAAmB,CAAC,IAAY,EAAE,OAAwC;IACxF,OAAO,eAAe,EAAE,CAAC,cAAc,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AACzD,CAAC;AAED,kGAAkG;AAClG,+BAA+B;AAC/B,MAAM,UAAU,qBAAqB,CAAC,YAAoB;IACxD,OAAO,eAAe,EAAE,CAAC,gBAAgB,CAAC,YAAY,CAAC,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,wBAAwB;IACtC,OAAO,GAAG,IAAI,CAAC;IACf,KAAK,GAAG,IAAI,CAAC;IACb,aAAa,GAAG,KAAK,CAAC;IACtB,gBAAgB,GAAG,IAAI,CAAC;AAC1B,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,eAAe,CAAC,OAA4B;IAC1D,OAAO,GAAG,OAAO,CAAC;AACpB,CAAC;AAED,kGAAkG;AAClG,wGAAwG;AACxG,uFAAuF;AACvF,uFAAuF;AACvF,MAAM,UAAU,0BAA0B,CAAC,OAAiC;IAC1E,mBAAmB,GAAG,OAAO,CAAC;AAChC,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,SAAS;IACvB,eAAe,EAAE,CAAC,IAAI,EAAE,CAAC;AAC3B,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,OAAO,eAAe,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC;AAClD,CAAC;AAED,2FAA2F;AAC3F,wEAAwE;AACxE,kGAAkG;AAClG,0EAA0E;AAC1E,MAAM,UAAU,sBAAsB,CACpC,YAAoB,EACpB,IAAiC,EACjC,SAAuC;IAEvC,OAAO,eAAe,EAAE,CAAC,iBAAiB,CAAC,YAAY,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;AAC5E,CAAC;AAED,MAAM,SAAS,GAAiB;IAC9B,IAAI,KAAI,CAAC;IACT,KAAK,CAAC,gBAAgB;QACpB,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,KAAK,CAAC,WAAW;QACf,OAAO,KAAK,CAAC;IACf,CAAC;IACD,KAAK,CAAC,YAAY;QAChB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,KAAK,CAAC,QAAQ;QACZ,OAAO,KAAK,CAAC;IACf,CAAC;IACD,KAAK,CAAC,cAAc;QAClB,OAAO,oBAAoB,CAAC;IAC9B,CAAC;IACD,KAAK,CAAC,gBAAgB;QACpB,OAAO,IAAI,CAAC;IACd,CAAC;IACD,KAAK,CAAC,gBAAgB;QACpB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB;QACrB,OAAO,KAAK,CAAC;IACf,CAAC;CACF,CAAC;AACF,IAAI,OAAO,GAAwB,IAAI,CAAC;AACxC,IAAI,KAAK,GAAwB,IAAI,CAAC;AACtC,IAAI,aAAa,GAAG,KAAK,CAAC;AAC1B,IAAI,gBAAgB,GAAqF,IAAI,CAAC;AAC9G,IAAI,mBAAmB,GAA6B,IAAI,CAAC"}
1
+ {"version":3,"file":"shell.js","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAkBA,sGAAsG;AACtG,4FAA4F;AAC5F,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,MAAwC;IACrF,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QACrE,OAAO,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,WAAW,EAAE,KAAK,MAAM,CAAC,CAAC;IACnF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,iGAAiG;AACjG,4EAA4E;AAC5E,MAAM,UAAU,qBAAqB,CACnC,IAAmB,EACnB,KAAwB;IAExB,OAAO,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAC9E,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,IAAmB,EAAE,IAAY;IACpE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;AAC5C,CAAC;AAED,qGAAqG;AACrG,mGAAmG;AACnG,wFAAwF;AACxF,MAAM,UAAU,oBAAoB,CAClC,IAAsB,EACtB,GAAW,EACX,MAAwC;IAExC,IAAI,CAAC,iBAAiB,CAAC,GAAG,EAAE,MAAM,CAAC;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAC1F,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,IAAsB,EAAE,IAAY;IAChE,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACxC,CAAC;AAED,MAAM,UAAU,qBAAqB,CACnC,IAA0B,EAC1B,YAAoB;IAEpB,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;AACpD,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,IAAwB,EAAE,IAAY;IACpE,OAAO,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAkB;IAC1C,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,sBAAsB,CACpC,IAA0B,EAC1B,YAAoB,EACpB,IAAiC,EACjC,SAAsC;IAEtC,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,YAAY,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;AACtE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/shell",
3
- "version": "0.5.0-next.1944.cd6b6e7",
3
+ "version": "0.5.1-edge.638.d4dbd4e",
4
4
  "author": "Joshua Granick and other contributors",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,11 +38,12 @@
38
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@flighthq/types": "0.5.0-next.1944.cd6b6e7"
41
+ "@flighthq/types": "0.5.1-edge.638.d4dbd4e"
42
42
  },
43
43
  "devDependencies": {
44
+ "@flighthq/entity": "*",
44
45
  "typescript": "^5.3.0"
45
46
  },
46
- "description": "OS shell integration over a swappable web/native backend: open external URLs and paths, reveal items in the file manager, move to trash, beep",
47
+ "description": "OS shell commands over six explicit Host capability slots with required external URL policy",
47
48
  "sideEffects": false
48
49
  }
package/src/shell.test.ts CHANGED
@@ -1,359 +1,206 @@
1
- import type { ShellBackend, ShellOpenExternalOptions, ShellOpenPathOptions } from '@flighthq/types/contract';
1
+ import { createEntity } from '@flighthq/entity/contract';
2
+ import type {
3
+ EntityRuntimeKey,
4
+ HasShellBeep,
5
+ HasShellExternal,
6
+ HasShellPathOpen,
7
+ HasShellPathReveal,
8
+ HasShellShortcutLink,
9
+ HasShellTrash,
10
+ ShellBeepBackend,
11
+ ShellExternalBackend,
12
+ ShellExternalUrlPolicy,
13
+ ShellPathOpenBackend,
14
+ ShellPathRevealBackend,
15
+ ShellShortcutLinkBackend,
16
+ ShellTrashBackend,
17
+ } from '@flighthq/types/contract';
2
18
 
3
19
  import {
4
- explainShellBackend,
5
- getShellBackend,
6
- installShellHostBackend,
7
20
  isShellUrlAllowed,
8
- moveItemsToTrash,
9
- moveItemToTrash,
10
- observeShellHostResult,
21
+ moveShellItemsToTrash,
22
+ moveShellItemToTrash,
11
23
  openShellExternalUrl,
12
24
  openShellPath,
13
- openShellPathResult,
14
25
  readShellShortcutLink,
15
- resetShellBackendForTest,
16
- setShellBackend,
17
- setShellUrlSchemeAllowlist,
26
+ revealShellPath,
18
27
  shellBeep,
19
- showItemInFolder,
20
28
  writeShellShortcutLink,
21
29
  } from './shell';
22
30
 
23
- function fakeBackend(): ShellBackend & {
24
- beeped: number;
25
- lastOptions: ShellOpenExternalOptions | ShellOpenPathOptions | undefined;
26
- opened: string;
27
- pathResult: string;
28
- trashed: string;
29
- trashedBatch: readonly string[];
30
- } {
31
- return {
32
- beeped: 0,
33
- lastOptions: undefined,
34
- opened: '',
35
- pathResult: '',
36
- trashed: '',
37
- trashedBatch: [],
38
- beep() {
39
- this.beeped += 1;
40
- },
41
- async moveItemsToTrash(paths) {
42
- this.trashedBatch = paths;
43
- return paths.map(() => true);
44
- },
45
- async moveToTrash(path) {
46
- this.trashed = path;
47
- return true;
48
- },
49
- async openExternal(url, options) {
50
- this.opened = url;
51
- this.lastOptions = options;
52
- return true;
53
- },
54
- async openPath(path, options) {
55
- this.opened = path;
56
- this.lastOptions = options;
57
- return true;
58
- },
59
- async openPathResult(path, options) {
60
- this.opened = path;
61
- this.lastOptions = options;
62
- return this.pathResult;
63
- },
64
- async readShortcutLink() {
65
- return { target: '/path/to/target' };
66
- },
67
- async showItemInFolder(path) {
68
- this.opened = path;
69
- return true;
70
- },
71
- async writeShortcutLink() {
72
- return true;
73
- },
74
- };
75
- }
76
-
77
- function resetShellTestState(): void {
78
- resetShellBackendForTest();
79
- setShellUrlSchemeAllowlist(null);
80
- }
81
-
82
- // The root fast suite deliberately shares modules between files. A host-adapter test can therefore
83
- // be the state immediately before this file; reset on entry as well as exit so the first assertion has
84
- // the same isolation as every later one.
85
- beforeEach(resetShellTestState);
86
- afterEach(resetShellTestState);
87
-
88
- describe('explainShellBackend', () => {
89
- it('returns host-not-enabled when no backend is installed', () => {
90
- expect(explainShellBackend()).toEqual({
91
- conflict: false,
92
- layer: 'host-not-enabled',
93
- operation: null,
94
- viability: 'unobserved',
95
- });
96
- });
97
-
98
- it('returns custom layer when a custom backend is set', () => {
99
- setShellBackend(fakeBackend());
100
- expect(explainShellBackend().layer).toBe('custom');
101
- });
102
-
103
- it('returns host layer when a host backend is installed', () => {
104
- installShellHostBackend(fakeBackend());
105
- expect(explainShellBackend().layer).toBe('host');
106
- });
107
- });
108
-
109
- describe('getShellBackend', () => {
110
- it('falls back to sentinel', () => {
111
- expect(getShellBackend()).not.toBeNull();
112
- });
113
-
114
- it('returns the registered backend', () => {
115
- const backend = fakeBackend();
116
- setShellBackend(backend);
117
- expect(getShellBackend()).toBe(backend);
118
- });
119
- });
120
-
121
- describe('installShellHostBackend', () => {
122
- it('first-host-wins and sets conflict on second different backend', () => {
123
- const first = fakeBackend();
124
- const second = fakeBackend();
125
- installShellHostBackend(first);
126
- installShellHostBackend(second);
127
- expect(explainShellBackend().conflict).toBe(true);
128
- });
129
-
130
- it('does not set conflict when the same backend is installed twice', () => {
131
- const backend = fakeBackend();
132
- installShellHostBackend(backend);
133
- installShellHostBackend(backend);
134
- expect(explainShellBackend().conflict).toBe(false);
135
- });
136
- });
137
-
138
31
  describe('isShellUrlAllowed', () => {
139
- it('returns true when no allowlist is set', () => {
140
- expect(isShellUrlAllowed('https://example.com')).toBe(true);
141
- expect(isShellUrlAllowed('file:///tmp/x')).toBe(true);
142
- });
143
-
144
- it('returns true for a scheme in the allowlist', () => {
145
- setShellUrlSchemeAllowlist(['https', 'mailto']);
146
- expect(isShellUrlAllowed('https://example.com')).toBe(true);
147
- expect(isShellUrlAllowed('mailto:user@example.com')).toBe(true);
148
- });
149
-
150
- it('returns false for a scheme not in the allowlist', () => {
151
- setShellUrlSchemeAllowlist(['https']);
152
- expect(isShellUrlAllowed('file:///tmp/x')).toBe(false);
153
- expect(isShellUrlAllowed('ftp://example.com')).toBe(false);
154
- });
155
-
156
- it('returns false for a URL that cannot be parsed', () => {
157
- setShellUrlSchemeAllowlist(['https']);
158
- expect(isShellUrlAllowed('not-a-url')).toBe(false);
159
- });
160
- });
161
-
162
- describe('moveItemsToTrash', () => {
163
- it('passes the path array to the backend and returns per-path results', async () => {
164
- const backend = fakeBackend();
165
- setShellBackend(backend);
166
- const results = await moveItemsToTrash(['/tmp/a', '/tmp/b']);
167
- expect(results).toEqual([true, true]);
168
- expect(backend.trashedBatch).toEqual(['/tmp/a', '/tmp/b']);
169
- });
170
- });
171
-
172
- describe('moveItemToTrash', () => {
173
- it('trashes via the active backend', async () => {
174
- const backend = fakeBackend();
175
- setShellBackend(backend);
176
- expect(await moveItemToTrash('/tmp/x')).toBe(true);
177
- expect(backend.trashed).toBe('/tmp/x');
32
+ it('matches schemes case-insensitively from an explicit policy', () => {
33
+ expect(isShellUrlAllowed('https://example.test', { allowedSchemes: ['HTTPS'] })).toBe(true);
34
+ expect(isShellUrlAllowed('mailto:user@example.test', { allowedSchemes: ['https'] })).toBe(false);
35
+ });
36
+
37
+ it('blocks malformed URLs and an empty policy', () => {
38
+ expect(isShellUrlAllowed('not a URL', { allowedSchemes: ['https'] })).toBe(false);
39
+ expect(isShellUrlAllowed('https://example.test', { allowedSchemes: [] })).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('moveShellItemsToTrash', () => {
44
+ it('starts every one-path operation and preserves input order while awaiting all results', async () => {
45
+ const paths: string[] = [];
46
+ const resolvers: Array<(outcome: { reason: 'ok' | 'operation-failed' }) => void> = [];
47
+ const host = trashHost(
48
+ createEntity({
49
+ moveToTrash(path) {
50
+ paths.push(path);
51
+ return new Promise((resolve) => resolvers.push(resolve));
52
+ },
53
+ } satisfies Omit<ShellTrashBackend, typeof EntityRuntimeKey>),
54
+ );
55
+
56
+ const outcomes = moveShellItemsToTrash(host, ['/first', '/second']);
57
+ expect(paths).toEqual(['/first', '/second']);
58
+ resolvers[1]?.({ reason: 'operation-failed' });
59
+ let settled = false;
60
+ void outcomes.then(() => {
61
+ settled = true;
62
+ });
63
+ await Promise.resolve();
64
+ expect(settled).toBe(false);
65
+ resolvers[0]?.({ reason: 'ok' });
66
+ await expect(outcomes).resolves.toEqual([{ reason: 'ok' }, { reason: 'operation-failed' }]);
178
67
  });
179
68
  });
180
69
 
181
- describe('observeShellHostResult', () => {
182
- it('records a successful observation into the explanation', () => {
183
- installShellHostBackend(fakeBackend());
184
- observeShellHostResult('openExternal', true);
185
- const explanation = explainShellBackend();
186
- expect(explanation.operation).toBe('openExternal');
187
- expect(explanation.viability).toBe('available');
188
- });
189
-
190
- it('records a failed observation into the explanation', () => {
191
- installShellHostBackend(fakeBackend());
192
- observeShellHostResult('openExternal', false);
193
- const explanation = explainShellBackend();
194
- expect(explanation.operation).toBe('openExternal');
195
- expect(explanation.viability).toBe('runtime-api-unavailable');
70
+ describe('moveShellItemToTrash', () => {
71
+ it('routes through the supplied trash provider', async () => {
72
+ const moveToTrash = vi.fn(async () => ({ reason: 'ok' as const }));
73
+ await expect(moveShellItemToTrash(trashHost(createEntity({ moveToTrash })), '/item')).resolves.toEqual({
74
+ reason: 'ok',
75
+ });
76
+ expect(moveToTrash).toHaveBeenCalledWith('/item');
196
77
  });
197
78
  });
198
79
 
199
80
  describe('openShellExternalUrl', () => {
200
- it('opens via the active backend', async () => {
201
- const backend = fakeBackend();
202
- setShellBackend(backend);
203
- expect(await openShellExternalUrl('https://example.com')).toBe(true);
204
- expect(backend.opened).toBe('https://example.com');
81
+ it('requires policy as the third parameter at the type boundary', () => {
82
+ expectTypeOf(openShellExternalUrl).parameters.toEqualTypeOf<
83
+ [HasShellExternal, string, Readonly<ShellExternalUrlPolicy>]
84
+ >();
205
85
  });
206
86
 
207
- it('forwards the activate option to the backend', async () => {
208
- const backend = fakeBackend();
209
- setShellBackend(backend);
210
- await openShellExternalUrl('https://example.com', { activate: true });
211
- expect(backend.lastOptions).toEqual({ activate: true });
212
- });
213
-
214
- it('returns false and does not call the backend when the URL scheme is blocked', async () => {
215
- const backend = fakeBackend();
216
- setShellBackend(backend);
217
- setShellUrlSchemeAllowlist(['https']);
218
- expect(await openShellExternalUrl('file:///etc/passwd')).toBe(false);
219
- expect(backend.opened).toBe('');
220
- });
221
-
222
- it('allows the URL when its scheme is in the allowlist', async () => {
223
- const backend = fakeBackend();
224
- setShellBackend(backend);
225
- setShellUrlSchemeAllowlist(['https', 'mailto']);
226
- expect(await openShellExternalUrl('https://example.com')).toBe(true);
227
- expect(backend.opened).toBe('https://example.com');
87
+ it('blocks before dispatch when the scheme is not allowed', async () => {
88
+ const open = vi.fn(async () => ({ reason: 'ok' as const }));
89
+ const host = externalHost(createEntity({ open }));
90
+ await expect(openShellExternalUrl(host, 'file:///etc/passwd', { allowedSchemes: ['https'] })).resolves.toEqual({
91
+ reason: 'blocked-scheme',
92
+ });
93
+ expect(open).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('keeps two live hosts isolated', async () => {
97
+ const calls: string[] = [];
98
+ const first = externalHost(
99
+ createEntity({
100
+ async open(url) {
101
+ calls.push(`first:${url}`);
102
+ return { reason: 'ok' };
103
+ },
104
+ } satisfies Omit<ShellExternalBackend, typeof EntityRuntimeKey>),
105
+ );
106
+ const second = externalHost(
107
+ createEntity({
108
+ async open(url) {
109
+ calls.push(`second:${url}`);
110
+ return { reason: 'operation-failed' };
111
+ },
112
+ } satisfies Omit<ShellExternalBackend, typeof EntityRuntimeKey>),
113
+ );
114
+ await expect(openShellExternalUrl(first, 'https://one.test', HTTPS_ONLY)).resolves.toEqual({ reason: 'ok' });
115
+ await expect(openShellExternalUrl(second, 'https://two.test', HTTPS_ONLY)).resolves.toEqual({
116
+ reason: 'operation-failed',
117
+ });
118
+ expect(calls).toEqual(['first:https://one.test', 'second:https://two.test']);
228
119
  });
229
120
  });
230
121
 
231
122
  describe('openShellPath', () => {
232
- it('opens via the active backend', async () => {
233
- const backend = fakeBackend();
234
- setShellBackend(backend);
235
- expect(await openShellPath('/tmp/x')).toBe(true);
236
- expect(backend.opened).toBe('/tmp/x');
237
- });
238
-
239
- it('forwards the workingDirectory option to the backend', async () => {
240
- const backend = fakeBackend();
241
- setShellBackend(backend);
242
- await openShellPath('/tmp/x', { workingDirectory: '/home/user' });
243
- expect(backend.lastOptions).toEqual({ workingDirectory: '/home/user' });
244
- });
245
-
246
- it('forwards the application option to the backend', async () => {
247
- const backend = fakeBackend();
248
- setShellBackend(backend);
249
- await openShellPath('/tmp/x', { application: 'TextEdit' });
250
- expect(backend.lastOptions).toEqual({ application: 'TextEdit' });
251
- });
252
-
253
- it('omits options when none provided', async () => {
254
- const backend = fakeBackend();
255
- setShellBackend(backend);
256
- await openShellPath('/tmp/x');
257
- expect(backend.lastOptions).toBeUndefined();
258
- });
259
- });
260
-
261
- describe('openShellPathResult', () => {
262
- it('returns an empty string on success', async () => {
263
- const backend = fakeBackend();
264
- backend.pathResult = '';
265
- setShellBackend(backend);
266
- expect(await openShellPathResult('/tmp/x')).toBe('');
267
- expect(backend.opened).toBe('/tmp/x');
268
- });
269
-
270
- it('returns the OS error string on failure', async () => {
271
- const backend = fakeBackend();
272
- backend.pathResult = 'No such file or directory';
273
- setShellBackend(backend);
274
- expect(await openShellPathResult('/nonexistent')).toBe('No such file or directory');
275
- });
276
-
277
- it('forwards options to the backend', async () => {
278
- const backend = fakeBackend();
279
- setShellBackend(backend);
280
- await openShellPathResult('/tmp/x', { workingDirectory: '/home/user' });
281
- expect(backend.lastOptions).toEqual({ workingDirectory: '/home/user' });
123
+ it('preserves a provider failure and its message', async () => {
124
+ const provider = createEntity({
125
+ async open() {
126
+ return { message: '', reason: 'operation-failed' as const };
127
+ },
128
+ });
129
+ await expect(openShellPath(pathOpenHost(provider), '/missing')).resolves.toEqual({
130
+ message: '',
131
+ reason: 'operation-failed',
132
+ });
282
133
  });
283
134
  });
284
135
 
285
136
  describe('readShellShortcutLink', () => {
286
- it('returns null with sentinel (no backend set)', async () => {
287
- const result = await readShellShortcutLink('/tmp/x.lnk');
288
- expect(result).toBeNull();
137
+ it('returns the read-specific outcome', async () => {
138
+ const provider = shortcutLinkProvider();
139
+ await expect(readShellShortcutLink(shortcutLinkHost(provider), '/app.lnk')).resolves.toEqual({
140
+ link: { target: '/app' },
141
+ reason: 'ok',
142
+ });
289
143
  });
144
+ });
290
145
 
291
- it('returns the shortcut link from the active backend', async () => {
292
- const backend = fakeBackend();
293
- setShellBackend(backend);
294
- const result = await readShellShortcutLink('/tmp/x.lnk');
295
- expect(result).toEqual({ target: '/path/to/target' });
146
+ describe('revealShellPath', () => {
147
+ it('returns the reveal-specific outcome', async () => {
148
+ const reveal = vi.fn(async () => ({ reason: 'operation-failed' as const }));
149
+ await expect(revealShellPath(pathRevealHost(createEntity({ reveal })), '/item')).resolves.toEqual({
150
+ reason: 'operation-failed',
151
+ });
296
152
  });
297
153
  });
298
154
 
299
- describe('resetShellBackendForTest', () => {
300
- it('clears all backend slots', () => {
301
- setShellBackend(fakeBackend());
302
- installShellHostBackend(fakeBackend());
303
- resetShellBackendForTest();
304
- expect(explainShellBackend().layer).toBe('host-not-enabled');
155
+ describe('shellBeep', () => {
156
+ it('dispatches synchronously', () => {
157
+ const beep = vi.fn();
158
+ shellBeep(beepHost(createEntity({ beep })));
159
+ expect(beep).toHaveBeenCalledOnce();
305
160
  });
306
161
  });
307
162
 
308
- describe('setShellBackend', () => {
309
- it('clears back to sentinel when passed null', () => {
310
- setShellBackend(fakeBackend());
311
- setShellBackend(null);
312
- expect(getShellBackend()).not.toBeNull();
163
+ describe('writeShellShortcutLink', () => {
164
+ it('requires an explicit write operation and returns the write-specific outcome', async () => {
165
+ const provider = shortcutLinkProvider();
166
+ await expect(
167
+ writeShellShortcutLink(shortcutLinkHost(provider), '/app.lnk', { target: '/app' }, 'replace'),
168
+ ).resolves.toEqual({ reason: 'ok' });
169
+ expect(provider.write).toHaveBeenCalledWith('/app.lnk', { target: '/app' }, 'replace');
313
170
  });
314
171
  });
315
172
 
316
- describe('setShellUrlSchemeAllowlist', () => {
317
- it('allows all URLs when set to null', () => {
318
- setShellUrlSchemeAllowlist(['https']);
319
- setShellUrlSchemeAllowlist(null);
320
- expect(isShellUrlAllowed('file:///tmp/x')).toBe(true);
321
- });
173
+ const HTTPS_ONLY: ShellExternalUrlPolicy = { allowedSchemes: ['https'] };
322
174
 
323
- it('restricts URLs to the listed schemes', () => {
324
- setShellUrlSchemeAllowlist(['https', 'mailto']);
325
- expect(isShellUrlAllowed('ftp://example.com')).toBe(false);
326
- });
327
- });
175
+ function beepHost(beep: ShellBeepBackend): HasShellBeep {
176
+ return { shell: { beep } };
177
+ }
328
178
 
329
- describe('shellBeep', () => {
330
- it('forwards to the active backend', () => {
331
- const backend = fakeBackend();
332
- setShellBackend(backend);
333
- shellBeep();
334
- expect(backend.beeped).toBe(1);
335
- });
336
- });
179
+ function externalHost(external: ShellExternalBackend): HasShellExternal {
180
+ return { shell: { external } };
181
+ }
337
182
 
338
- describe('showItemInFolder', () => {
339
- it('reveals via the active backend', async () => {
340
- const backend = fakeBackend();
341
- setShellBackend(backend);
342
- expect(await showItemInFolder('/tmp/x')).toBe(true);
343
- expect(backend.opened).toBe('/tmp/x');
344
- });
345
- });
183
+ function pathOpenHost(pathOpen: ShellPathOpenBackend): HasShellPathOpen {
184
+ return { shell: { pathOpen } };
185
+ }
346
186
 
347
- describe('writeShellShortcutLink', () => {
348
- it('returns false with sentinel (no backend set)', async () => {
349
- const result = await writeShellShortcutLink('/tmp/x.lnk', { target: '/tmp/target' });
350
- expect(result).toBe(false);
351
- });
187
+ function pathRevealHost(pathReveal: ShellPathRevealBackend): HasShellPathReveal {
188
+ return { shell: { pathReveal } };
189
+ }
352
190
 
353
- it('writes via the active backend', async () => {
354
- const backend = fakeBackend();
355
- setShellBackend(backend);
356
- const result = await writeShellShortcutLink('/tmp/x.lnk', { target: '/tmp/target' }, 'create');
357
- expect(result).toBe(true);
358
- });
359
- });
191
+ function shortcutLinkHost(shortcutLink: ShellShortcutLinkBackend): HasShellShortcutLink {
192
+ return { shell: { shortcutLink } };
193
+ }
194
+
195
+ function shortcutLinkProvider(): ShellShortcutLinkBackend & { write: ReturnType<typeof vi.fn> } {
196
+ return createEntity({
197
+ async read() {
198
+ return { link: { target: '/app' }, reason: 'ok' };
199
+ },
200
+ write: vi.fn(async () => ({ reason: 'ok' as const })),
201
+ } satisfies Omit<ShellShortcutLinkBackend, typeof EntityRuntimeKey>);
202
+ }
203
+
204
+ function trashHost(trash: ShellTrashBackend): HasShellTrash {
205
+ return { shell: { trash } };
206
+ }