sh3-server 0.6.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.
- package/README.md +9 -0
- package/app/assets/SH3-BG5NVpSD.png +0 -0
- package/app/assets/icons-CnAqUqbR.svg +1126 -0
- package/app/assets/index-B1mKDfA-.css +1 -0
- package/app/assets/index-C7fUtJvb.js +9 -0
- package/app/assets/tauri-backend-B3LR3-lo.js +1 -0
- package/app/index.html +13 -0
- package/dist/auth.d.ts +34 -0
- package/dist/auth.js +107 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +30 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +228 -0
- package/dist/keys.d.ts +33 -0
- package/dist/keys.js +68 -0
- package/dist/packages.d.ts +38 -0
- package/dist/packages.js +256 -0
- package/dist/routes/admin.d.ts +10 -0
- package/dist/routes/admin.js +126 -0
- package/dist/routes/auth.d.ts +13 -0
- package/dist/routes/auth.js +97 -0
- package/dist/routes/boot.d.ts +11 -0
- package/dist/routes/boot.js +56 -0
- package/dist/routes/docs.d.ts +14 -0
- package/dist/routes/docs.js +133 -0
- package/dist/routes/env-state.d.ts +8 -0
- package/dist/routes/env-state.js +62 -0
- package/dist/sessions.d.ts +26 -0
- package/dist/sessions.js +59 -0
- package/dist/settings.d.ts +22 -0
- package/dist/settings.js +63 -0
- package/dist/shard-router.d.ts +68 -0
- package/dist/shard-router.js +145 -0
- package/dist/shell-shard/history-store.d.ts +11 -0
- package/dist/shell-shard/history-store.js +65 -0
- package/dist/shell-shard/index.d.ts +14 -0
- package/dist/shell-shard/index.js +51 -0
- package/dist/shell-shard/runner.d.ts +22 -0
- package/dist/shell-shard/runner.js +84 -0
- package/dist/shell-shard/session-manager.d.ts +56 -0
- package/dist/shell-shard/session-manager.js +161 -0
- package/dist/shell-shard/tokenize.d.ts +1 -0
- package/dist/shell-shard/tokenize.js +68 -0
- package/dist/shell-shard/ws.d.ts +2 -0
- package/dist/shell-shard/ws.js +78 -0
- package/dist/users.d.ts +44 -0
- package/dist/users.js +113 -0
- package/package.json +49 -0
- package/static/404.html +53 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Server half of shell-shard — statically mounted by sh3-server at boot.
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* GET /api/shell/history — JSON, admin-only. Returns persisted history
|
|
6
|
+
* for the authenticated user.
|
|
7
|
+
* GET /api/shell/session — WebSocket upgrade, admin-only. Attaches the
|
|
8
|
+
* client to the per-user session.
|
|
9
|
+
*/
|
|
10
|
+
import { LocalRunner } from './runner.js';
|
|
11
|
+
import { SessionManager } from './session-manager.js';
|
|
12
|
+
import { handleClientMessage } from './ws.js';
|
|
13
|
+
function sessionUser(c) {
|
|
14
|
+
const session = c.get('session') ?? c.env?.session;
|
|
15
|
+
return session?.userId ?? 'admin';
|
|
16
|
+
}
|
|
17
|
+
export default {
|
|
18
|
+
id: 'shell',
|
|
19
|
+
async routes(app, ctx) {
|
|
20
|
+
// Default config — overridable via shard env state in a future pass.
|
|
21
|
+
const cfg = { ringSize: 500, historyMaxLines: 10_000, defaultCwd: '' };
|
|
22
|
+
const runner = new LocalRunner();
|
|
23
|
+
const manager = new SessionManager(ctx.dataDir, runner, cfg);
|
|
24
|
+
// NOTE: the JSON /history endpoint is convenience — the authoritative
|
|
25
|
+
// delivery of history is via the `history` server message sent on WS
|
|
26
|
+
// attach. Clients can skip the REST endpoint entirely. Kept for
|
|
27
|
+
// symmetry with the spec's WebSocket protocol section. Reads the
|
|
28
|
+
// same HistoryStore the session uses, so it reflects live state.
|
|
29
|
+
app.get('/history', ctx.adminOnly, (c) => {
|
|
30
|
+
const user = sessionUser(c);
|
|
31
|
+
const session = manager.getOrCreate(user);
|
|
32
|
+
return c.json({ lines: session.readHistory() });
|
|
33
|
+
});
|
|
34
|
+
app.get('/session', ctx.adminOnly, ctx.wsRegister((ws, c) => {
|
|
35
|
+
// Route this connection to the per-user ShellSession. sessionUser
|
|
36
|
+
// reads from the upgrade Context forwarded by wsRegister, so two
|
|
37
|
+
// admin users get isolated sessions with their own history, cwd,
|
|
38
|
+
// and running process.
|
|
39
|
+
const userId = sessionUser(c);
|
|
40
|
+
const session = manager.getOrCreate(userId);
|
|
41
|
+
session.attach(ws);
|
|
42
|
+
// Wire incoming frames through the dispatch layer.
|
|
43
|
+
ws.addEventListener?.('message', (evt) => {
|
|
44
|
+
handleClientMessage(session, ws, String(evt.data));
|
|
45
|
+
});
|
|
46
|
+
ws.addEventListener?.('close', () => {
|
|
47
|
+
session.detach(ws);
|
|
48
|
+
});
|
|
49
|
+
}));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
export interface RunnerStartOpts {
|
|
3
|
+
cmd: string;
|
|
4
|
+
cwd: string;
|
|
5
|
+
env: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export interface RunnerHandle {
|
|
8
|
+
/** Send bytes to the process's stdin. No-op for LocalRunner in v1. */
|
|
9
|
+
write(data: string): void;
|
|
10
|
+
/** Send a signal. SIGINT kills the process; EOF closes stdin. */
|
|
11
|
+
signal(sig: 'SIGINT' | 'EOF'): void;
|
|
12
|
+
/** Force-close the handle and stop emitting events. */
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
/** Event emitter: 'stdout' (string), 'stderr' (string), 'exit' ({code, signal}). */
|
|
15
|
+
readonly events: EventEmitter;
|
|
16
|
+
}
|
|
17
|
+
export interface Runner {
|
|
18
|
+
start(opts: RunnerStartOpts): Promise<RunnerHandle>;
|
|
19
|
+
}
|
|
20
|
+
export declare class LocalRunner implements Runner {
|
|
21
|
+
start(opts: RunnerStartOpts): Promise<RunnerHandle>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Runner interface — abstraction over "how a command runs".
|
|
3
|
+
*
|
|
4
|
+
* v1 implementation: LocalRunner (child_process.spawn against PATH).
|
|
5
|
+
*
|
|
6
|
+
* Deferred implementation: SshRunner (ssh2-based, per-session connection
|
|
7
|
+
* to a remote host). The SshRunner slots in without any client, protocol,
|
|
8
|
+
* or view changes — only a new verb (`:connect user@host`) switches the
|
|
9
|
+
* active runner for a session. See spec § Deferred.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { EventEmitter } from 'node:events';
|
|
13
|
+
import { tokenize } from './tokenize.js';
|
|
14
|
+
export class LocalRunner {
|
|
15
|
+
async start(opts) {
|
|
16
|
+
const events = new EventEmitter();
|
|
17
|
+
let child;
|
|
18
|
+
try {
|
|
19
|
+
const [bin, ...args] = tokenize(opts.cmd);
|
|
20
|
+
if (!bin) {
|
|
21
|
+
// Empty command — emit an error exit without spawning
|
|
22
|
+
queueMicrotask(() => {
|
|
23
|
+
events.emit('stderr', 'shell: empty command\n');
|
|
24
|
+
events.emit('exit', { code: null, signal: 'spawn-error' });
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
write: () => { },
|
|
28
|
+
signal: () => { },
|
|
29
|
+
close: async () => { },
|
|
30
|
+
events,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
child = spawn(bin, args, {
|
|
34
|
+
cwd: opts.cwd,
|
|
35
|
+
env: opts.env,
|
|
36
|
+
shell: false,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const message = err.message ?? String(err);
|
|
41
|
+
queueMicrotask(() => {
|
|
42
|
+
events.emit('stderr', `shell: ${message}\n`);
|
|
43
|
+
events.emit('exit', { code: null, signal: 'spawn-error' });
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
write: () => { },
|
|
47
|
+
signal: () => { },
|
|
48
|
+
close: async () => { },
|
|
49
|
+
events,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
child.stdout?.setEncoding('utf-8');
|
|
53
|
+
child.stderr?.setEncoding('utf-8');
|
|
54
|
+
child.stdout?.on('data', (data) => events.emit('stdout', data));
|
|
55
|
+
child.stderr?.on('data', (data) => events.emit('stderr', data));
|
|
56
|
+
child.on('error', (err) => {
|
|
57
|
+
events.emit('stderr', `shell: ${err.message}\n`);
|
|
58
|
+
events.emit('exit', { code: null, signal: 'spawn-error' });
|
|
59
|
+
});
|
|
60
|
+
child.on('exit', (code, signal) => {
|
|
61
|
+
events.emit('exit', { code, signal });
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
write: (_data) => {
|
|
65
|
+
// Stdin piping is deferred in v1 — see spec § Deferred.
|
|
66
|
+
// When implemented, this becomes: child.stdin?.write(_data)
|
|
67
|
+
},
|
|
68
|
+
signal: (sig) => {
|
|
69
|
+
if (sig === 'SIGINT') {
|
|
70
|
+
child.kill('SIGINT');
|
|
71
|
+
}
|
|
72
|
+
else if (sig === 'EOF') {
|
|
73
|
+
child.stdin?.end();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
close: async () => {
|
|
77
|
+
if (!child.killed && child.exitCode === null) {
|
|
78
|
+
child.kill('SIGKILL');
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
events,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Runner } from './runner.js';
|
|
2
|
+
import type { ServerEvent } from 'sh3-core';
|
|
3
|
+
import { HistoryStore } from './history-store.js';
|
|
4
|
+
export interface SessionConfig {
|
|
5
|
+
ringSize: number;
|
|
6
|
+
historyMaxLines: number;
|
|
7
|
+
defaultCwd: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Minimal WebSocket interface — any object with .send(string) satisfies it.
|
|
11
|
+
* The server's real WS implementation (from @hono/node-ws) provides a richer
|
|
12
|
+
* surface; we intentionally narrow it here so tests can use plain mocks.
|
|
13
|
+
*/
|
|
14
|
+
export interface WsLike {
|
|
15
|
+
send(data: string): void;
|
|
16
|
+
close(): void;
|
|
17
|
+
}
|
|
18
|
+
export declare class ShellSession {
|
|
19
|
+
readonly userId: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
env: Record<string, string>;
|
|
22
|
+
private readonly history;
|
|
23
|
+
private readonly runner;
|
|
24
|
+
private readonly ringSize;
|
|
25
|
+
private ring;
|
|
26
|
+
private readonly clients;
|
|
27
|
+
private nextSeq;
|
|
28
|
+
private running;
|
|
29
|
+
private starting;
|
|
30
|
+
constructor(userId: string, runner: Runner, history: HistoryStore, cfg: SessionConfig);
|
|
31
|
+
attach(ws: WsLike): void;
|
|
32
|
+
detach(ws: WsLike): void;
|
|
33
|
+
broadcast(event: ServerEvent): void;
|
|
34
|
+
/**
|
|
35
|
+
* Submit a command line. Ignored if a process is already running.
|
|
36
|
+
* Appends the line to history and starts the runner. Stdout/stderr/
|
|
37
|
+
* exit are broadcast as events.
|
|
38
|
+
*/
|
|
39
|
+
submit(line: string, _fromWs: WsLike): Promise<void>;
|
|
40
|
+
/** Record a line in history without running anything (for local SH3 verbs). */
|
|
41
|
+
historyLog(line: string): void;
|
|
42
|
+
/** Read the persisted history buffer — used by the JSON /history endpoint. */
|
|
43
|
+
readHistory(): string[];
|
|
44
|
+
/** Forward a signal to the running process, if any. */
|
|
45
|
+
signal(sig: 'SIGINT' | 'EOF'): void;
|
|
46
|
+
/** Change cwd and broadcast a cwd update to every attached client. */
|
|
47
|
+
setCwd(cwd: string): void;
|
|
48
|
+
}
|
|
49
|
+
export declare class SessionManager {
|
|
50
|
+
private readonly sessions;
|
|
51
|
+
private readonly dataDir;
|
|
52
|
+
private readonly runner;
|
|
53
|
+
private readonly cfg;
|
|
54
|
+
constructor(dataDir: string, runner: Runner, cfg: SessionConfig);
|
|
55
|
+
getOrCreate(userId: string): ShellSession;
|
|
56
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Session manager for shell-shard.
|
|
3
|
+
*
|
|
4
|
+
* One ShellSession per admin user. Every shell view the user opens
|
|
5
|
+
* attaches to the same session. The session outlives individual views
|
|
6
|
+
* and dies only on server restart (v1 — see spec § Deferred for
|
|
7
|
+
* persistence across restart).
|
|
8
|
+
*
|
|
9
|
+
* A session holds:
|
|
10
|
+
* - cwd, env (runtime state)
|
|
11
|
+
* - HistoryStore (persistent append-only JSONL)
|
|
12
|
+
* - a circular ring buffer of recent events for replay on attach
|
|
13
|
+
* - the set of currently attached WebSocket clients
|
|
14
|
+
* - at most one running RunnerHandle
|
|
15
|
+
*/
|
|
16
|
+
import { HistoryStore } from './history-store.js';
|
|
17
|
+
export class ShellSession {
|
|
18
|
+
userId;
|
|
19
|
+
cwd;
|
|
20
|
+
env;
|
|
21
|
+
history;
|
|
22
|
+
runner;
|
|
23
|
+
ringSize;
|
|
24
|
+
ring = [];
|
|
25
|
+
clients = new Set();
|
|
26
|
+
nextSeq = 1;
|
|
27
|
+
running = null;
|
|
28
|
+
starting = false;
|
|
29
|
+
constructor(userId, runner, history, cfg) {
|
|
30
|
+
this.userId = userId;
|
|
31
|
+
this.runner = runner;
|
|
32
|
+
this.history = history;
|
|
33
|
+
this.ringSize = cfg.ringSize;
|
|
34
|
+
this.cwd = cfg.defaultCwd || process.cwd();
|
|
35
|
+
this.env = { ...process.env, FORCE_COLOR: '1' };
|
|
36
|
+
}
|
|
37
|
+
attach(ws) {
|
|
38
|
+
this.clients.add(ws);
|
|
39
|
+
const welcome = {
|
|
40
|
+
t: 'welcome',
|
|
41
|
+
userId: this.userId,
|
|
42
|
+
cwd: this.cwd,
|
|
43
|
+
env: this.env,
|
|
44
|
+
seq: this.nextSeq - 1,
|
|
45
|
+
};
|
|
46
|
+
ws.send(JSON.stringify(welcome));
|
|
47
|
+
const replay = { t: 'replay', events: [...this.ring] };
|
|
48
|
+
ws.send(JSON.stringify(replay));
|
|
49
|
+
const history = { t: 'history', lines: this.history.read() };
|
|
50
|
+
ws.send(JSON.stringify(history));
|
|
51
|
+
}
|
|
52
|
+
detach(ws) {
|
|
53
|
+
this.clients.delete(ws);
|
|
54
|
+
// Do NOT kill running process on detach — sessions outlive views.
|
|
55
|
+
}
|
|
56
|
+
broadcast(event) {
|
|
57
|
+
const stamped = { ...event, seq: this.nextSeq++ };
|
|
58
|
+
// Append to ring, trim from head if over capacity
|
|
59
|
+
this.ring.push(stamped);
|
|
60
|
+
if (this.ring.length > this.ringSize) {
|
|
61
|
+
this.ring.splice(0, this.ring.length - this.ringSize);
|
|
62
|
+
}
|
|
63
|
+
// Fan out to every attached client
|
|
64
|
+
const msg = { t: 'event', event: stamped };
|
|
65
|
+
const wire = JSON.stringify(msg);
|
|
66
|
+
for (const ws of this.clients) {
|
|
67
|
+
ws.send(wire);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Submit a command line. Ignored if a process is already running.
|
|
72
|
+
* Appends the line to history and starts the runner. Stdout/stderr/
|
|
73
|
+
* exit are broadcast as events.
|
|
74
|
+
*/
|
|
75
|
+
async submit(line, _fromWs) {
|
|
76
|
+
if (this.running || this.starting) {
|
|
77
|
+
console.warn(`[shell-shard] submit while running — ignored (user=${this.userId})`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Claim the start slot synchronously so a concurrent submit during
|
|
81
|
+
// the `await runner.start()` below cannot slip past the lock check.
|
|
82
|
+
this.starting = true;
|
|
83
|
+
try {
|
|
84
|
+
this.history.append(line);
|
|
85
|
+
// Emit a prompt event so every attached client echoes the line
|
|
86
|
+
this.broadcast({
|
|
87
|
+
kind: 'prompt',
|
|
88
|
+
line,
|
|
89
|
+
cwd: this.cwd,
|
|
90
|
+
ts: Date.now(),
|
|
91
|
+
seq: 0,
|
|
92
|
+
});
|
|
93
|
+
const handle = await this.runner.start({
|
|
94
|
+
cmd: line,
|
|
95
|
+
cwd: this.cwd,
|
|
96
|
+
env: this.env,
|
|
97
|
+
});
|
|
98
|
+
this.running = handle;
|
|
99
|
+
handle.events.on('stdout', (data) => {
|
|
100
|
+
this.broadcast({ kind: 'stdout', data, ts: Date.now(), seq: 0 });
|
|
101
|
+
});
|
|
102
|
+
handle.events.on('stderr', (data) => {
|
|
103
|
+
this.broadcast({ kind: 'stderr', data, ts: Date.now(), seq: 0 });
|
|
104
|
+
});
|
|
105
|
+
handle.events.on('exit', (info) => {
|
|
106
|
+
this.broadcast({
|
|
107
|
+
kind: 'exit',
|
|
108
|
+
code: info.code,
|
|
109
|
+
signal: info.signal,
|
|
110
|
+
ts: Date.now(),
|
|
111
|
+
seq: 0,
|
|
112
|
+
});
|
|
113
|
+
this.running = null;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
this.starting = false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Record a line in history without running anything (for local SH3 verbs). */
|
|
121
|
+
historyLog(line) {
|
|
122
|
+
this.history.append(line);
|
|
123
|
+
}
|
|
124
|
+
/** Read the persisted history buffer — used by the JSON /history endpoint. */
|
|
125
|
+
readHistory() {
|
|
126
|
+
return this.history.read();
|
|
127
|
+
}
|
|
128
|
+
/** Forward a signal to the running process, if any. */
|
|
129
|
+
signal(sig) {
|
|
130
|
+
this.running?.signal(sig);
|
|
131
|
+
}
|
|
132
|
+
/** Change cwd and broadcast a cwd update to every attached client. */
|
|
133
|
+
setCwd(cwd) {
|
|
134
|
+
this.cwd = cwd;
|
|
135
|
+
const msg = { t: 'cwd', cwd };
|
|
136
|
+
const wire = JSON.stringify(msg);
|
|
137
|
+
for (const ws of this.clients) {
|
|
138
|
+
ws.send(wire);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export class SessionManager {
|
|
143
|
+
sessions = new Map();
|
|
144
|
+
dataDir;
|
|
145
|
+
runner;
|
|
146
|
+
cfg;
|
|
147
|
+
constructor(dataDir, runner, cfg) {
|
|
148
|
+
this.dataDir = dataDir;
|
|
149
|
+
this.runner = runner;
|
|
150
|
+
this.cfg = cfg;
|
|
151
|
+
}
|
|
152
|
+
getOrCreate(userId) {
|
|
153
|
+
let session = this.sessions.get(userId);
|
|
154
|
+
if (!session) {
|
|
155
|
+
const history = new HistoryStore(this.dataDir, userId, this.cfg.historyMaxLines);
|
|
156
|
+
session = new ShellSession(userId, this.runner, history, this.cfg);
|
|
157
|
+
this.sessions.set(userId, session);
|
|
158
|
+
}
|
|
159
|
+
return session;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function tokenize(line: string): string[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Minimal argv tokenizer for shell-shard.
|
|
3
|
+
*
|
|
4
|
+
* Splits a command line into argv tokens. Supports:
|
|
5
|
+
* - Bare tokens separated by whitespace
|
|
6
|
+
* - Double-quoted strings ("…") with backslash-escapes (\" and \\)
|
|
7
|
+
* - Single-quoted strings ('…') with no escapes (literal content)
|
|
8
|
+
*
|
|
9
|
+
* Does NOT support (deferred; see spec § Deferred):
|
|
10
|
+
* - Pipes, redirections, glob expansion, env var expansion
|
|
11
|
+
* - Subshells, backticks, $(…)
|
|
12
|
+
*
|
|
13
|
+
* Users who need shell features can wrap commands with `bash -c '…'`.
|
|
14
|
+
*
|
|
15
|
+
* Throws on unterminated quoted strings.
|
|
16
|
+
*/
|
|
17
|
+
export function tokenize(line) {
|
|
18
|
+
const tokens = [];
|
|
19
|
+
let i = 0;
|
|
20
|
+
const n = line.length;
|
|
21
|
+
while (i < n) {
|
|
22
|
+
// Skip whitespace between tokens
|
|
23
|
+
while (i < n && isSpace(line[i]))
|
|
24
|
+
i++;
|
|
25
|
+
if (i >= n)
|
|
26
|
+
break;
|
|
27
|
+
let current = '';
|
|
28
|
+
while (i < n && !isSpace(line[i])) {
|
|
29
|
+
const ch = line[i];
|
|
30
|
+
if (ch === '"') {
|
|
31
|
+
i++; // consume opening quote
|
|
32
|
+
while (i < n && line[i] !== '"') {
|
|
33
|
+
if (line[i] === '\\' && i + 1 < n) {
|
|
34
|
+
// Escaped char — keep the next character literally
|
|
35
|
+
current += line[i + 1];
|
|
36
|
+
i += 2;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
current += line[i];
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (i >= n)
|
|
44
|
+
throw new Error('tokenize: unterminated double-quoted string');
|
|
45
|
+
i++; // consume closing quote
|
|
46
|
+
}
|
|
47
|
+
else if (ch === "'") {
|
|
48
|
+
i++; // consume opening quote
|
|
49
|
+
while (i < n && line[i] !== "'") {
|
|
50
|
+
current += line[i];
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
if (i >= n)
|
|
54
|
+
throw new Error('tokenize: unterminated single-quoted string');
|
|
55
|
+
i++; // consume closing quote
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
current += ch;
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
tokens.push(current);
|
|
63
|
+
}
|
|
64
|
+
return tokens;
|
|
65
|
+
}
|
|
66
|
+
function isSpace(ch) {
|
|
67
|
+
return ch === ' ' || ch === '\t';
|
|
68
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WebSocket message dispatch for shell-shard.
|
|
3
|
+
*
|
|
4
|
+
* Decodes incoming ClientMessage frames and calls the appropriate method
|
|
5
|
+
* on the ShellSession. Narrow, pure: this file has no Hono/ws specifics —
|
|
6
|
+
* it just routes decoded messages to sessions. The routes() function in
|
|
7
|
+
* `index.ts` wires the real sockets through this.
|
|
8
|
+
*/
|
|
9
|
+
import { resolve, isAbsolute } from 'node:path';
|
|
10
|
+
import { existsSync, statSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
export function handleClientMessage(session, ws, raw) {
|
|
13
|
+
let msg;
|
|
14
|
+
try {
|
|
15
|
+
msg = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Malformed frame — ignore, do not crash the session.
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
switch (msg.t) {
|
|
22
|
+
case 'hello':
|
|
23
|
+
// attach() already sent welcome+replay+history at connection time.
|
|
24
|
+
// The only meaningful thing here is a late hello with replayFrom,
|
|
25
|
+
// which v1 ignores — the client can resync by reconnecting.
|
|
26
|
+
return;
|
|
27
|
+
case 'submit': {
|
|
28
|
+
const trimmed = msg.line.trim();
|
|
29
|
+
if (trimmed.startsWith('cd ') || trimmed === 'cd') {
|
|
30
|
+
// Server-managed cd — don't spawn, update session cwd directly.
|
|
31
|
+
const target = trimmed === 'cd' ? '' : trimmed.slice(3).trim();
|
|
32
|
+
handleCd(session, target);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
void session.submit(msg.line, ws);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
case 'signal':
|
|
39
|
+
session.signal(msg.sig);
|
|
40
|
+
return;
|
|
41
|
+
case 'history-log':
|
|
42
|
+
session.historyLog(msg.line);
|
|
43
|
+
return;
|
|
44
|
+
case 'cwd-query':
|
|
45
|
+
// Re-emit a cwd update message (reuses setCwd() broadcast path).
|
|
46
|
+
session.setCwd(session.cwd);
|
|
47
|
+
return;
|
|
48
|
+
default: {
|
|
49
|
+
// Exhaustiveness check. If a new ClientMessage variant is added to
|
|
50
|
+
// the protocol without a handler here, TypeScript flags this line.
|
|
51
|
+
const _exhaustive = msg;
|
|
52
|
+
void _exhaustive;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Handle a `cd` command server-side: resolve the target path, validate it
|
|
59
|
+
* exists and is a directory, then update session.cwd via setCwd() which
|
|
60
|
+
* broadcasts a 'cwd' message to every attached client.
|
|
61
|
+
*/
|
|
62
|
+
function handleCd(session, target) {
|
|
63
|
+
const dest = target === '' || target === '~'
|
|
64
|
+
? homedir()
|
|
65
|
+
: isAbsolute(target)
|
|
66
|
+
? target
|
|
67
|
+
: resolve(session.cwd, target);
|
|
68
|
+
if (!existsSync(dest) || !statSync(dest).isDirectory()) {
|
|
69
|
+
session.broadcast({
|
|
70
|
+
seq: 0,
|
|
71
|
+
kind: 'stderr',
|
|
72
|
+
data: `cd: no such directory: ${target}\n`,
|
|
73
|
+
ts: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
session.setCwd(dest);
|
|
78
|
+
}
|
package/dist/users.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User management — CRUD with bcrypt password hashing.
|
|
3
|
+
* Persists to {dataDir}/users.json.
|
|
4
|
+
*/
|
|
5
|
+
export interface StoredUser {
|
|
6
|
+
id: string;
|
|
7
|
+
username: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
passwordHash: string;
|
|
10
|
+
role: 'admin' | 'user';
|
|
11
|
+
createdAt: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
}
|
|
14
|
+
/** Public user shape — never exposes passwordHash. */
|
|
15
|
+
export type PublicUser = Omit<StoredUser, 'passwordHash'>;
|
|
16
|
+
export declare class UserStore {
|
|
17
|
+
#private;
|
|
18
|
+
constructor(dataDir: string);
|
|
19
|
+
/** True when no users exist (first boot). */
|
|
20
|
+
isEmpty(): boolean;
|
|
21
|
+
/** Create a user. Returns the public user + the raw password (for first-boot printing). */
|
|
22
|
+
create(opts: {
|
|
23
|
+
username: string;
|
|
24
|
+
displayName: string;
|
|
25
|
+
password: string;
|
|
26
|
+
role: 'admin' | 'user';
|
|
27
|
+
}): Promise<PublicUser>;
|
|
28
|
+
/** Verify username + password. Returns public user or null. */
|
|
29
|
+
authenticate(username: string, password: string): Promise<PublicUser | null>;
|
|
30
|
+
/** List all users (public shape). */
|
|
31
|
+
list(): PublicUser[];
|
|
32
|
+
/** Get a user by ID (public shape). */
|
|
33
|
+
get(id: string): PublicUser | null;
|
|
34
|
+
/** Update a user. Returns updated public user or null if not found. */
|
|
35
|
+
update(id: string, patch: {
|
|
36
|
+
displayName?: string;
|
|
37
|
+
password?: string;
|
|
38
|
+
role?: 'admin' | 'user';
|
|
39
|
+
}): Promise<PublicUser | null>;
|
|
40
|
+
/** Delete a user by ID. Returns true if found and removed. */
|
|
41
|
+
delete(id: string): boolean;
|
|
42
|
+
/** Generate a random temporary password. */
|
|
43
|
+
static generatePassword(): string;
|
|
44
|
+
}
|
package/dist/users.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User management — CRUD with bcrypt password hashing.
|
|
3
|
+
* Persists to {dataDir}/users.json.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
8
|
+
// Lazy-load bcrypt — the native addon is unavailable in Node SEA builds
|
|
9
|
+
// (Tauri sidecar). That's fine: Tauri never hits auth code paths.
|
|
10
|
+
let _bcrypt = null;
|
|
11
|
+
async function getBcrypt() {
|
|
12
|
+
if (!_bcrypt)
|
|
13
|
+
_bcrypt = await import('bcrypt');
|
|
14
|
+
return _bcrypt;
|
|
15
|
+
}
|
|
16
|
+
const SALT_ROUNDS = 10;
|
|
17
|
+
export class UserStore {
|
|
18
|
+
#path;
|
|
19
|
+
#users = [];
|
|
20
|
+
constructor(dataDir) {
|
|
21
|
+
this.#path = `${dataDir}/users.json`;
|
|
22
|
+
this.#load();
|
|
23
|
+
}
|
|
24
|
+
#load() {
|
|
25
|
+
if (existsSync(this.#path)) {
|
|
26
|
+
try {
|
|
27
|
+
this.#users = JSON.parse(readFileSync(this.#path, 'utf-8'));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
this.#users = [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
#save() {
|
|
35
|
+
mkdirSync(dirname(this.#path), { recursive: true });
|
|
36
|
+
writeFileSync(this.#path, JSON.stringify(this.#users, null, 2));
|
|
37
|
+
}
|
|
38
|
+
/** True when no users exist (first boot). */
|
|
39
|
+
isEmpty() {
|
|
40
|
+
return this.#users.length === 0;
|
|
41
|
+
}
|
|
42
|
+
/** Create a user. Returns the public user + the raw password (for first-boot printing). */
|
|
43
|
+
async create(opts) {
|
|
44
|
+
const lower = opts.username.toLowerCase();
|
|
45
|
+
if (this.#users.some(u => u.username === lower)) {
|
|
46
|
+
throw new Error(`Username "${lower}" already exists`);
|
|
47
|
+
}
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
const user = {
|
|
50
|
+
id: randomUUID(),
|
|
51
|
+
username: lower,
|
|
52
|
+
displayName: opts.displayName,
|
|
53
|
+
passwordHash: await (await getBcrypt()).hash(opts.password, SALT_ROUNDS),
|
|
54
|
+
role: opts.role,
|
|
55
|
+
createdAt: now,
|
|
56
|
+
updatedAt: now,
|
|
57
|
+
};
|
|
58
|
+
this.#users.push(user);
|
|
59
|
+
this.#save();
|
|
60
|
+
return this.#toPublic(user);
|
|
61
|
+
}
|
|
62
|
+
/** Verify username + password. Returns public user or null. */
|
|
63
|
+
async authenticate(username, password) {
|
|
64
|
+
const user = this.#users.find(u => u.username === username.toLowerCase());
|
|
65
|
+
if (!user)
|
|
66
|
+
return null;
|
|
67
|
+
if (!(await (await getBcrypt()).compare(password, user.passwordHash)))
|
|
68
|
+
return null;
|
|
69
|
+
return this.#toPublic(user);
|
|
70
|
+
}
|
|
71
|
+
/** List all users (public shape). */
|
|
72
|
+
list() {
|
|
73
|
+
return this.#users.map(u => this.#toPublic(u));
|
|
74
|
+
}
|
|
75
|
+
/** Get a user by ID (public shape). */
|
|
76
|
+
get(id) {
|
|
77
|
+
const user = this.#users.find(u => u.id === id);
|
|
78
|
+
return user ? this.#toPublic(user) : null;
|
|
79
|
+
}
|
|
80
|
+
/** Update a user. Returns updated public user or null if not found. */
|
|
81
|
+
async update(id, patch) {
|
|
82
|
+
const user = this.#users.find(u => u.id === id);
|
|
83
|
+
if (!user)
|
|
84
|
+
return null;
|
|
85
|
+
if (patch.displayName !== undefined)
|
|
86
|
+
user.displayName = patch.displayName;
|
|
87
|
+
if (patch.password !== undefined)
|
|
88
|
+
user.passwordHash = await (await getBcrypt()).hash(patch.password, SALT_ROUNDS);
|
|
89
|
+
if (patch.role !== undefined)
|
|
90
|
+
user.role = patch.role;
|
|
91
|
+
user.updatedAt = new Date().toISOString();
|
|
92
|
+
this.#save();
|
|
93
|
+
return this.#toPublic(user);
|
|
94
|
+
}
|
|
95
|
+
/** Delete a user by ID. Returns true if found and removed. */
|
|
96
|
+
delete(id) {
|
|
97
|
+
const before = this.#users.length;
|
|
98
|
+
this.#users = this.#users.filter(u => u.id !== id);
|
|
99
|
+
if (this.#users.length < before) {
|
|
100
|
+
this.#save();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
/** Generate a random temporary password. */
|
|
106
|
+
static generatePassword() {
|
|
107
|
+
return randomBytes(6).toString('base64url');
|
|
108
|
+
}
|
|
109
|
+
#toPublic(user) {
|
|
110
|
+
const { passwordHash: _, ...pub } = user;
|
|
111
|
+
return pub;
|
|
112
|
+
}
|
|
113
|
+
}
|