Skip to content

Commit e153305

Browse files
committed
feat(recipes): validate openInEditor's editor arg against known editors
Load devframe/utils/launch-editor and devframe/utils/open lazily via dynamic import in the openInEditor/openInFinder handlers instead of a top-level import. openInEditor gains an optional second argument to pick the editor command explicitly. It's validated against a new KNOWN_EDITORS constant (and KnownEditor type) via v.picklist, so the RPC surface can't be used to spawn an arbitrary command.
1 parent 3cba445 commit e153305

7 files changed

Lines changed: 100 additions & 30 deletions

File tree

docs/helpers/common-rpc-functions.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ defineDevframe({
2222

2323
| Export | Registered name | Type | Args | Purpose |
2424
|--------|------------------|------|------|---------|
25-
| `openInEditor` | `devframe:open-in-editor` | `action` | `[filename: string]` | Open the file in the user's editor via [`launchEditor`](./utilities#devframe-utils-launch-editor). Accepts `file`, `file:line`, or `file:line:column`. |
25+
| `openInEditor` | `devframe:open-in-editor` | `action` | `[filename: string, editor?: KnownEditor]` | Open the file in the user's editor via [`launchEditor`](./utilities#devframe-utils-launch-editor). `filename` accepts `file`, `file:line`, or `file:line:column`. The optional `editor` picks the editor command explicitly instead of relying on auto-detection. |
2626
| `openInFinder` | `devframe:open-in-finder` | `action` | `[path: string]` | Reveal the path in the OS file explorer via [`open`](./utilities#devframe-utils-open). |
2727
| `commonRpcFunctions` || `readonly [openInEditor, openInFinder]` || Convenience array for batch registration. |
28+
| `KNOWN_EDITORS` || `readonly string[]` || The editor commands `openInEditor`'s `editor` argument accepts (`code`, `vim`, `subl`, `idea`, …). |
29+
| `KnownEditor` || type || Union of `KNOWN_EDITORS`. |
2830

29-
Both functions are `action`-type RPCs returning `void` and use `valibot` schemas (`v.string()`) for their single argument.
31+
Both functions are `action`-type RPCs returning `void` and use `valibot` schemas for their arguments — `openInEditor`'s `editor` argument is `v.optional(v.picklist(KNOWN_EDITORS))`, so a value outside `KNOWN_EDITORS` fails validation rather than reaching the underlying `launch-editor` process spawn. Both handlers dynamically `import()` their underlying `devframe/utils/*` implementation, so the `launch-editor` and `open` dependencies only load when the recipe actually runs.
3032

3133
The `devframe/recipes/open-helpers` entry (`openHelpers`) remains as a deprecated alias for this module — new code should import `commonRpcFunctions` from `devframe/recipes/common-rpc-functions`.
3234

@@ -52,7 +54,8 @@ The SPA calls these like any other RPC:
5254
```ts
5355
const rpc = await connectDevframe()
5456
await rpc.call('devframe:open-in-editor', 'src/main.ts:42:7')
57+
await rpc.call('devframe:open-in-editor', 'src/main.ts:42:7', 'code')
5558
await rpc.call('devframe:open-in-finder', '/abs/path/to/dir')
5659
```
5760

58-
`launchEditor`'s editor auto-detection reads the `LAUNCH_EDITOR` environment variable on the server side — there is no client-side configuration.
61+
`launchEditor`'s editor auto-detection reads the `LAUNCH_EDITOR` environment variable on the server side when no `editor` argument is passed — there is no client-side configuration.

packages/devframe/src/recipes/__tests__/common-rpc-functions.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
1+
import * as v from 'valibot'
12
import { describe, expect, it } from 'vitest'
2-
import { commonRpcFunctions, openInEditor, openInFinder } from '../common-rpc-functions'
3+
import { commonRpcFunctions, KNOWN_EDITORS, openInEditor, openInFinder } from '../common-rpc-functions'
34
import { openHelpers } from '../open-helpers'
45

56
describe('recipes/common-rpc-functions', () => {
67
it('exposes `openInEditor` as a devframe-namespaced action', () => {
78
expect(openInEditor.name).toBe('devframe:open-in-editor')
89
expect(openInEditor.type).toBe('action')
9-
expect(openInEditor.args).toHaveLength(1)
10+
expect(openInEditor.args).toHaveLength(2)
1011
expect(typeof openInEditor.handler).toBe('function')
1112
})
1213

14+
it('restricts `openInEditor`\'s optional second argument to `KNOWN_EDITORS`', () => {
15+
expect(KNOWN_EDITORS).toContain('code')
16+
expect(KNOWN_EDITORS).toContain('vim')
17+
18+
const editorSchema = openInEditor.args[1]
19+
expect(v.safeParse(editorSchema, undefined).success).toBe(true)
20+
for (const editor of KNOWN_EDITORS)
21+
expect(v.safeParse(editorSchema, editor).success).toBe(true)
22+
expect(v.safeParse(editorSchema, 'not-a-real-editor').success).toBe(false)
23+
})
24+
1325
it('exposes `openInFinder` as a devframe-namespaced action', () => {
1426
expect(openInFinder.name).toBe('devframe:open-in-finder')
1527
expect(openInFinder.type).toBe('action')

packages/devframe/src/recipes/common-rpc-functions.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,60 @@
1-
import { launchEditor } from 'devframe/utils/launch-editor'
2-
import { open } from 'devframe/utils/open'
31
import * as v from 'valibot'
42
import { defineRpcFunction } from '../rpc/define'
53

4+
/**
5+
* Editor commands that `launch-editor` (the library behind
6+
* `devframe/utils/launch-editor`) recognizes with a tailored
7+
* `file:line:column` invocation. `openInEditor`'s optional second argument
8+
* is restricted to this list, so the RPC surface can't be used to spawn an
9+
* arbitrary command.
10+
*/
11+
export const KNOWN_EDITORS = [
12+
'atom',
13+
'subl',
14+
'sublime',
15+
'sublime_text',
16+
'wstorm',
17+
'charm',
18+
'zed',
19+
'notepad++',
20+
'vim',
21+
'mvim',
22+
'joe',
23+
'gvim',
24+
'emacs',
25+
'emacsclient',
26+
'rmate',
27+
'mate',
28+
'code',
29+
'code-insiders',
30+
'codium',
31+
'vscodium',
32+
'trae',
33+
'antigravity',
34+
'cursor',
35+
'appcode',
36+
'clion',
37+
'idea',
38+
'phpstorm',
39+
'pycharm',
40+
'rubymine',
41+
'webstorm',
42+
'goland',
43+
'rider',
44+
] as const
45+
46+
/** One of the editor commands in {@link KNOWN_EDITORS}. */
47+
export type KnownEditor = (typeof KNOWN_EDITORS)[number]
48+
649
/**
750
* Prebuilt RPC action that opens a file in the user's configured editor.
851
*
952
* Registered name: `devframe:open-in-editor`.
1053
*
54+
* The optional second argument picks the editor command explicitly (must be
55+
* one of {@link KNOWN_EDITORS}); otherwise it's auto-detected per
56+
* `devframe/utils/launch-editor`.
57+
*
1158
* ```ts
1259
* import { openInEditor } from 'devframe/recipes/common-rpc-functions'
1360
*
@@ -24,10 +71,11 @@ export const openInEditor = defineRpcFunction({
2471
name: 'devframe:open-in-editor',
2572
type: 'action',
2673
jsonSerializable: true,
27-
args: [v.string()],
74+
args: [v.string(), v.optional(v.picklist(KNOWN_EDITORS))],
2875
returns: v.void(),
29-
async handler(filename: string) {
30-
launchEditor(filename)
76+
async handler(filename: string, editor?: KnownEditor) {
77+
const { launchEditor } = await import('devframe/utils/launch-editor')
78+
launchEditor(filename, editor)
3179
},
3280
})
3381

@@ -49,6 +97,7 @@ export const openInFinder = defineRpcFunction({
4997
args: [v.string()],
5098
returns: v.void(),
5199
async handler(path: string) {
100+
const { open } = await import('devframe/utils/open')
52101
await open(path)
53102
},
54103
})

tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/recipes/common-rpc-functions`
33
*/
4+
// #region Types
5+
export type KnownEditor = (typeof KNOWN_EDITORS)[number];
6+
// #endregion
7+
48
// #region Variables
59
export declare const commonRpcFunctions: readonly [{
610
name: "devframe:open-in-editor";
711
type?: "action" | undefined;
812
cacheable?: boolean;
9-
args: readonly [v.StringSchema<undefined>];
13+
args: readonly [v.StringSchema<undefined>, v.OptionalSchema<v.PicklistSchema<readonly ["atom", "subl", "sublime", "sublime_text", "wstorm", "charm", "zed", "notepad++", "vim", "mvim", "joe", "gvim", "emacs", "emacsclient", "rmate", "mate", "code", "code-insiders", "codium", "vscodium", "trae", "antigravity", "cursor", "appcode", "clion", "idea", "phpstorm", "pycharm", "rubymine", "webstorm", "goland", "rider"], undefined>, undefined>];
1014
returns: v.VoidSchema<undefined>;
1115
jsonSerializable?: boolean;
1216
agent?: RpcFunctionAgentOptions;
13-
setup?: ((context: undefined) => Thenable<RpcFunctionSetupResult<[string], void>>) | undefined;
14-
handler?: ((args_0: string) => void) | undefined;
15-
dump?: RpcDump<[string], void, undefined> | undefined;
17+
setup?: ((context: undefined) => Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>>) | undefined;
18+
handler?: ((args_0: string, args_1: "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined) => void) | undefined;
19+
dump?: RpcDump<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void, undefined> | undefined;
1620
snapshot?: boolean;
17-
__cache?: WeakMap<object, Thenable<RpcFunctionSetupResult<[string], void>>> | undefined;
18-
__promise?: Thenable<RpcFunctionSetupResult<[string], void>> | undefined;
21+
__cache?: WeakMap<object, Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>>> | undefined;
22+
__promise?: Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>> | undefined;
1923
}, {
2024
name: "devframe:open-in-finder";
2125
type?: "action" | undefined;
@@ -31,20 +35,21 @@ export declare const commonRpcFunctions: readonly [{
3135
__cache?: WeakMap<object, Thenable<RpcFunctionSetupResult<[string], void>>> | undefined;
3236
__promise?: Thenable<RpcFunctionSetupResult<[string], void>> | undefined;
3337
}];
38+
export declare const KNOWN_EDITORS: readonly ["atom", "subl", "sublime", "sublime_text", "wstorm", "charm", "zed", "notepad++", "vim", "mvim", "joe", "gvim", "emacs", "emacsclient", "rmate", "mate", "code", "code-insiders", "codium", "vscodium", "trae", "antigravity", "cursor", "appcode", "clion", "idea", "phpstorm", "pycharm", "rubymine", "webstorm", "goland", "rider"];
3439
export declare const openInEditor: {
3540
name: "devframe:open-in-editor";
3641
type?: "action" | undefined;
3742
cacheable?: boolean;
38-
args: readonly [v.StringSchema<undefined>];
43+
args: readonly [v.StringSchema<undefined>, v.OptionalSchema<v.PicklistSchema<readonly ["atom", "subl", "sublime", "sublime_text", "wstorm", "charm", "zed", "notepad++", "vim", "mvim", "joe", "gvim", "emacs", "emacsclient", "rmate", "mate", "code", "code-insiders", "codium", "vscodium", "trae", "antigravity", "cursor", "appcode", "clion", "idea", "phpstorm", "pycharm", "rubymine", "webstorm", "goland", "rider"], undefined>, undefined>];
3944
returns: v.VoidSchema<undefined>;
4045
jsonSerializable?: boolean;
4146
agent?: RpcFunctionAgentOptions;
42-
setup?: ((context: undefined) => Thenable<RpcFunctionSetupResult<[string], void>>) | undefined;
43-
handler?: ((args_0: string) => void) | undefined;
44-
dump?: RpcDump<[string], void, undefined> | undefined;
47+
setup?: ((context: undefined) => Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>>) | undefined;
48+
handler?: ((args_0: string, args_1: "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined) => void) | undefined;
49+
dump?: RpcDump<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void, undefined> | undefined;
4550
snapshot?: boolean;
46-
__cache?: WeakMap<object, Thenable<RpcFunctionSetupResult<[string], void>>> | undefined;
47-
__promise?: Thenable<RpcFunctionSetupResult<[string], void>> | undefined;
51+
__cache?: WeakMap<object, Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>>> | undefined;
52+
__promise?: Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>> | undefined;
4853
};
4954
export declare const openInFinder: {
5055
name: "devframe:open-in-finder";

tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
// #region Variables
55
export var commonRpcFunctions /* const */
6+
export var KNOWN_EDITORS /* const */
67
export var openInEditor /* const */
78
export var openInFinder /* const */
89
// #endregion

tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ export declare const openInEditor: {
88
name: "devframe:open-in-editor";
99
type?: "action" | undefined;
1010
cacheable?: boolean;
11-
args: readonly [v.StringSchema<undefined>];
11+
args: readonly [v.StringSchema<undefined>, v.OptionalSchema<v.PicklistSchema<readonly ["atom", "subl", "sublime", "sublime_text", "wstorm", "charm", "zed", "notepad++", "vim", "mvim", "joe", "gvim", "emacs", "emacsclient", "rmate", "mate", "code", "code-insiders", "codium", "vscodium", "trae", "antigravity", "cursor", "appcode", "clion", "idea", "phpstorm", "pycharm", "rubymine", "webstorm", "goland", "rider"], undefined>, undefined>];
1212
returns: v.VoidSchema<undefined>;
1313
jsonSerializable?: boolean;
1414
agent?: RpcFunctionAgentOptions;
15-
setup?: ((context: undefined) => Thenable<RpcFunctionSetupResult<[string], void>>) | undefined;
16-
handler?: ((args_0: string) => void) | undefined;
17-
dump?: RpcDump<[string], void, undefined> | undefined;
15+
setup?: ((context: undefined) => Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>>) | undefined;
16+
handler?: ((args_0: string, args_1: "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined) => void) | undefined;
17+
dump?: RpcDump<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void, undefined> | undefined;
1818
snapshot?: boolean;
19-
__cache?: WeakMap<object, Thenable<RpcFunctionSetupResult<[string], void>>> | undefined;
20-
__promise?: Thenable<RpcFunctionSetupResult<[string], void>> | undefined;
19+
__cache?: WeakMap<object, Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>>> | undefined;
20+
__promise?: Thenable<RpcFunctionSetupResult<[string, "atom" | "subl" | "sublime" | "sublime_text" | "wstorm" | "charm" | "zed" | "notepad++" | "vim" | "mvim" | "joe" | "gvim" | "emacs" | "emacsclient" | "rmate" | "mate" | "code" | "code-insiders" | "codium" | "vscodium" | "trae" | "antigravity" | "cursor" | "appcode" | "clion" | "idea" | "phpstorm" | "pycharm" | "rubymine" | "webstorm" | "goland" | "rider" | undefined], void>> | undefined;
2121
};
2222
export declare const openInFinder: {
2323
name: "devframe:open-in-finder";
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/utils/launch-editor`
33
*/
4-
// #region Other
5-
export { launchEditor }
4+
// #region Functions
5+
export function launchEditor(_, _) {}
66
// #endregion

0 commit comments

Comments
 (0)