Skip to content

Commit eca5f22

Browse files
feat(terminal): add support for redirects and allow specific commands (#76)
1 parent 4c39cc6 commit eca5f22

File tree

7 files changed

+290
-69
lines changed

7 files changed

+290
-69
lines changed

docs/tutorialkit.dev/src/content/docs/guides/configuration.mdx

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ You can configure the appearance and behavior of a TutorialKit lesson by setting
88

99
## Note on inheritance
1010

11-
Some options, like "title," will naturally be unique for each lesson. For others, like "template," the value might be the same across multiple lessons, chapters, or even an entire tutorial. That's why we've made it possible to set some properties on a **chapter**, **part**, or **tutorial** level. We call these values _inherited_.
11+
Some options, like "title," will naturally be unique for each lesson. For others, like "template," the value might be the same across multiple lessons, chapters, or even an entire tutorial. That's why we've made it possible to set some properties on a **chapter**, **part**, or **tutorial** level. We call these values _inherited_.
1212

13-
For instance, if you set `template: "simple"` for a given **part**, all chapters and lessons in this **part** will use the "simple" template.
13+
For instance, if you set `template: "simple"` for a given **part**, all chapters and lessons in this **part** will use the "simple" template.
1414

1515
It's also possible to override inherited properties on a lower level. For example, if you set `template: "simple"` for a **part**, but `template: "advanced"` for a **lesson**, that specific lesson will use the "advanced" template.
1616

@@ -38,16 +38,16 @@ Defines which file should be opened in the [code editor](/guides/ui/#code-editor
3838
<PropertyTable inherited type="string" />
3939

4040
##### `previews`
41-
Configure which ports should be used for the previews allowing you to align the behavior with your demo application's dev server setup. If not specified, the lowest port will be used.
41+
Configure which ports should be used for the previews allowing you to align the behavior with your demo application's dev server setup. If not specified, the lowest port will be used.
4242

43-
You can optionally provide these as an array of tuples where the first element is the port number and the second is the name of the preview, or as an object.
43+
You can optionally provide these as an array of tuples where the first element is the port number and the second is the title of the preview, or as an object.
4444
<PropertyTable inherited type={'Preview[]'} />
4545

4646
The `Preview` type has the following shape:
4747

4848
```ts
49-
type Preview = string
50-
| [port: number, title: string]
49+
type Preview = string
50+
| [port: number, title: string]
5151
| { port: number, title: string }
5252

5353
```
@@ -59,8 +59,8 @@ The main command to be executed. This command will run after the `prepareCommand
5959
The `Command` type has the following shape:
6060

6161
```ts
62-
type Command = string
63-
| [command: string, title: string]
62+
type Command = string
63+
| [command: string, title: string]
6464
| { command: string, title: string }
6565

6666
```
@@ -72,8 +72,8 @@ List of commands to execute sequentially. They are typically used to install dep
7272
The `Command` type has the following shape:
7373

7474
```ts
75-
type Command = string
76-
| [command: string, title: string]
75+
type Command = string
76+
| [command: string, title: string]
7777
| { command: string, title: string }
7878

7979
```
@@ -83,6 +83,10 @@ Configures one or more terminals. TutorialKit provides two types of terminals: r
8383

8484
You can define which terminal panel will be active by default by specifying the `activePanel` value. The value is the given terminal's position in the `panels` array. If you omit the `activePanel` property, the first panel will be the active one.
8585

86+
An interactive terminal will disable the output redirect syntax by default. For instance, you cannot create a file `world.txt` with the contents `hello` using the command `echo hello > world.txt`. The reason is that this could disrupt the lesson if a user overwrites certain files. To allow output redirection, you can change the behavior with the `allowRedirects` setting. You can define this setting either per panel or for all panels at once.
87+
88+
Additionally, you may not want users to run arbitrary commands. For example, if you are creating a lesson about `vitest`, you could specify that the only command the user can run is `vitest` by providing a list of `allowCommands`. Any other command executed by the user will be blocked. You can define the `allowCommands` setting either per panel or for all panels at once.
89+
8690
By default, in every new lesson terminals start a new session. If you want to keep the terminal session between lessons, you can specify the `id` property for a given terminal panel and keep the same `id` across lessons.
8791
<PropertyTable inherited type="Terminal" />
8892

@@ -91,12 +95,14 @@ The `Terminal` type has the following shape:
9195
```ts
9296
type Terminal = {
9397
panels: TerminalPanel[],
94-
activePanel?: number
98+
activePanel?: number,
99+
allowRedirects?: boolean,
100+
allowCommands?: string[]
95101
}
96102

97103
type TerminalPanel = TerminalType
98-
| [type: TerminalType, name: string]
99-
| [type: TerminalType, { name: string, id: string }]
104+
| [type: TerminalType, title: string]
105+
| { type: TerminalType, title?: string, id?: string, allowRedirects?: boolean, allowCommands?: boolean }
100106

101107
type TerminalType = 'terminal' | 'output'
102108

@@ -106,10 +112,16 @@ Example value:
106112

107113
```yaml
108114
terminal:
115+
activePanel: 1
109116
panels:
110117
- ['output', 'Dev Server']
111-
- ['terminal', { name: 'Command Line', id: 'cmds' }]
112-
activePanel: 1
118+
- type: terminal
119+
id: 'cmds'
120+
title: 'Command Line'
121+
allowRedirects: true,
122+
allowCommands:
123+
- ls
124+
- echo
113125

114126
```
115127

packages/components/react/src/Panels/TerminalPanel.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function TerminalPanel({ theme, tutorialStore }: TerminalPanelProps) {
5252
role="tablist"
5353
aria-orientation="horizontal"
5454
>
55-
{terminalConfig.panels.map(({ type, name }, index) => {
55+
{terminalConfig.panels.map(({ type, title }, index) => {
5656
const selected = tabIndex === index;
5757

5858
return (
@@ -69,7 +69,7 @@ export function TerminalPanel({ theme, tutorialStore }: TerminalPanelProps) {
6969
'border-l': index > 0,
7070
},
7171
)}
72-
title={name}
72+
title={title}
7373
aria-selected={selected}
7474
onClick={() => setTabIndex(index)}
7575
>
@@ -79,7 +79,7 @@ export function TerminalPanel({ theme, tutorialStore }: TerminalPanelProps) {
7979
'text-tk-elements-panel-headerTab-iconColorActive': selected,
8080
})}
8181
></span>
82-
{name}
82+
{title}
8383
</button>
8484
</li>
8585
);

packages/runtime/src/store/terminal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class TerminalStore {
5656
// if the panel is a terminal panel, and this panel didn't exist before, spawn a new JSH process
5757
this._bootWebContainer(panel)
5858
.then(async (webcontainerInstance) => {
59-
panel.attachProcess(await newJSHProcess(webcontainerInstance, panel));
59+
panel.attachProcess(await newJSHProcess(webcontainerInstance, panel, panel.processOptions));
6060
})
6161
.catch(() => {
6262
// do nothing

packages/runtime/src/webcontainer/shell.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,37 @@ import type { WebContainer } from '@webcontainer/api';
22
import { withResolvers } from '../utils/promises.js';
33
import type { ITerminal } from '../utils/terminal.js';
44

5-
export async function newJSHProcess(webcontainer: WebContainer, terminal: ITerminal) {
5+
interface ProcessOptions {
6+
/**
7+
* Set to `true` if you want to allow redirecting output (e.g. `echo foo > bar`).
8+
*/
9+
allowRedirects: boolean;
10+
11+
/**
12+
* List of commands that are allowed by the JSH process.
13+
*/
14+
allowCommands?: string[];
15+
}
16+
17+
export async function newJSHProcess(
18+
webcontainer: WebContainer,
19+
terminal: ITerminal,
20+
options: ProcessOptions | undefined,
21+
) {
22+
const args: string[] = [];
23+
24+
if (!options?.allowRedirects) {
25+
// if redirects are turned off, start JSH with `--no-redirects`
26+
args.push('--no-redirects');
27+
}
28+
29+
if (Array.isArray(options?.allowCommands)) {
30+
// if only a subset of commands is allowed, pass it down to JSH
31+
args.push('--allow-commands', options.allowCommands.join(','));
32+
}
33+
634
// we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
7-
const process = await webcontainer.spawn('/bin/jsh', ['--osc'], {
35+
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
836
terminal: {
937
cols: terminal.cols ?? 80,
1038
rows: terminal.rows ?? 15,

packages/runtime/src/webcontainer/terminal-config.spec.ts

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ describe('TerminalConfig', () => {
2222
const config = new TerminalConfig(true);
2323

2424
expect(config.panels.length).toBe(1);
25-
expect(config.panels[0].name).toBe('Output');
25+
expect(config.panels[0].title).toBe('Output');
2626
});
2727

2828
it('should have only output panel when config is undefined', () => {
2929
const config = new TerminalConfig();
3030

3131
expect(config.panels.length).toBe(1);
32-
expect(config.panels[0].name).toBe('Output');
32+
expect(config.panels[0].title).toBe('Output');
3333
});
3434

3535
it('should have panels with custom names', () => {
@@ -40,31 +40,157 @@ describe('TerminalConfig', () => {
4040
],
4141
});
4242

43-
expect(config.panels[0].name).toBe('Foo');
44-
expect(config.panels[1].name).toBe('Bar');
43+
expect(config.panels[0].title).toBe('Foo');
44+
expect(config.panels[1].title).toBe('Bar');
4545
});
4646

4747
it('should have panels with custom names and ids', () => {
4848
const config = new TerminalConfig({
4949
panels: [
50-
['terminal', { name: 'Foo', id: 'foo' }],
51-
['terminal', { name: 'Bar', id: 'bar' }],
50+
{ type: 'terminal', title: 'Foo', id: 'foo' },
51+
{ type: 'terminal', title: 'Bar', id: 'bar' },
5252
],
5353
});
5454

55-
expect(config.panels[0].name).toBe('Foo');
55+
expect(config.panels[0].title).toBe('Foo');
5656
expect(config.panels[0].id).toBe('foo');
57-
expect(config.panels[1].name).toBe('Bar');
57+
expect(config.panels[0].processOptions).toEqual({
58+
allowRedirects: false,
59+
allowCommands: undefined,
60+
});
61+
expect(config.panels[1].title).toBe('Bar');
5862
expect(config.panels[1].id).toBe('bar');
63+
expect(config.panels[1].processOptions).toEqual({
64+
allowRedirects: false,
65+
allowCommands: undefined,
66+
});
67+
});
68+
69+
it('should allow redirects and only allow certain commands when providing a panel type', () => {
70+
const config = new TerminalConfig({
71+
allowRedirects: true,
72+
allowCommands: ['echo'],
73+
panels: 'terminal',
74+
});
75+
76+
expect(config.panels[0].title).toBe('Terminal');
77+
expect(config.panels[0].processOptions).toEqual({
78+
allowRedirects: true,
79+
allowCommands: ['echo'],
80+
});
81+
});
82+
83+
it('should allow redirects and only allow certain commands when providing a list of panel types', () => {
84+
const config = new TerminalConfig({
85+
allowRedirects: true,
86+
allowCommands: ['echo'],
87+
panels: ['terminal', 'terminal', 'output'],
88+
});
89+
90+
expect(config.panels[0].title).toBe('Terminal');
91+
expect(config.panels[0].processOptions).toEqual({
92+
allowRedirects: true,
93+
allowCommands: ['echo'],
94+
});
95+
96+
expect(config.panels[1].title).toBe('Terminal 1');
97+
expect(config.panels[1].processOptions).toEqual({
98+
allowRedirects: true,
99+
allowCommands: ['echo'],
100+
});
101+
102+
expect(config.panels[2].title).toBe('Output');
103+
expect(config.panels[2].processOptions).toBeUndefined();
104+
});
105+
106+
it('should allow redirects and only allow certain commands when providing a list of panel tuples', () => {
107+
const config = new TerminalConfig({
108+
allowRedirects: true,
109+
allowCommands: ['echo'],
110+
panels: [
111+
['terminal', 'TERM 1'],
112+
['terminal', 'TERM 2'],
113+
['output', 'OUT'],
114+
],
115+
});
116+
117+
expect(config.panels[0].title).toBe('TERM 1');
118+
expect(config.panels[0].processOptions).toEqual({
119+
allowRedirects: true,
120+
allowCommands: ['echo'],
121+
});
122+
123+
expect(config.panels[1].title).toBe('TERM 2');
124+
expect(config.panels[1].processOptions).toEqual({
125+
allowRedirects: true,
126+
allowCommands: ['echo'],
127+
});
128+
129+
expect(config.panels[2].title).toBe('OUT');
130+
expect(config.panels[2].processOptions).toBeUndefined();
131+
});
132+
133+
it('should allow redirects and only allow certain commands when providing a list of panel objects', () => {
134+
const config = new TerminalConfig({
135+
allowRedirects: true,
136+
allowCommands: ['echo'],
137+
panels: [
138+
{ type: 'terminal', title: 'TERM 1' },
139+
{ type: 'terminal', title: 'TERM 2' },
140+
{ type: 'output', title: 'OUT' },
141+
],
142+
});
143+
144+
expect(config.panels[0].title).toBe('TERM 1');
145+
expect(config.panels[0].processOptions).toEqual({
146+
allowRedirects: true,
147+
allowCommands: ['echo'],
148+
});
149+
150+
expect(config.panels[1].title).toBe('TERM 2');
151+
expect(config.panels[1].processOptions).toEqual({
152+
allowRedirects: true,
153+
allowCommands: ['echo'],
154+
});
155+
156+
expect(config.panels[2].title).toBe('OUT');
157+
expect(config.panels[2].processOptions).toBeUndefined();
158+
});
159+
160+
it('should allow overwriting `allowRedirects` and `allowCommands` per panel', () => {
161+
const config = new TerminalConfig({
162+
allowRedirects: true,
163+
allowCommands: ['echo'],
164+
panels: [
165+
{ type: 'terminal', title: 'TERM 1', allowRedirects: false },
166+
{ type: 'terminal', title: 'TERM 2', allowCommands: ['ls'] },
167+
{ type: 'output', title: 'OUT', allowRedirects: false },
168+
],
169+
});
170+
171+
expect(config.panels[0].title).toBe('TERM 1');
172+
expect(config.panels[0].processOptions).toEqual({
173+
allowRedirects: false,
174+
allowCommands: ['echo'],
175+
});
176+
177+
expect(config.panels[1].title).toBe('TERM 2');
178+
expect(config.panels[1].processOptions).toEqual({
179+
allowRedirects: true,
180+
allowCommands: ['ls'],
181+
});
182+
183+
expect(config.panels[2].title).toBe('OUT');
184+
expect(config.panels[2].processOptions).toBeUndefined();
59185
});
60186

61187
it('should have panels and activePanel with inferred names', () => {
62188
const config = new TerminalConfig({
63189
panels: ['output', 'terminal', 'terminal'],
64190
});
65191

66-
expect(config.panels[0].name).toBe('Output');
67-
expect(config.panels[1].name).toBe('Terminal');
68-
expect(config.panels[2].name).toBe('Terminal 1');
192+
expect(config.panels[0].title).toBe('Output');
193+
expect(config.panels[1].title).toBe('Terminal');
194+
expect(config.panels[2].title).toBe('Terminal 1');
69195
});
70196
});

0 commit comments

Comments
 (0)