sh3-core 0.7.3 → 0.8.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/dist/__test__/fixtures.d.ts +12 -0
- package/dist/__test__/fixtures.js +62 -0
- package/dist/__test__/render.d.ts +3 -0
- package/dist/__test__/render.js +11 -0
- package/dist/__test__/reset.d.ts +14 -0
- package/dist/__test__/reset.js +34 -0
- package/dist/__test__/setup-dom.d.ts +1 -0
- package/dist/__test__/setup-dom.js +26 -0
- package/dist/__test__/smoke.test.d.ts +1 -0
- package/dist/__test__/smoke.test.js +28 -0
- package/dist/api.d.ts +15 -2
- package/dist/api.js +13 -1
- package/dist/app/store/StoreView.svelte +36 -7
- package/dist/app/store/storeShard.svelte.js +9 -3
- package/dist/app/store/verbs.js +8 -2
- package/dist/apps/lifecycle.d.ts +11 -0
- package/dist/apps/lifecycle.js +48 -11
- package/dist/apps/lifecycle.test.d.ts +1 -0
- package/dist/apps/lifecycle.test.js +309 -0
- package/dist/apps/registry.svelte.d.ts +2 -0
- package/dist/apps/registry.svelte.js +5 -0
- package/dist/apps/types.d.ts +24 -2
- package/dist/createShell.d.ts +2 -0
- package/dist/createShell.js +9 -7
- package/dist/documents/handle.js +5 -0
- package/dist/documents/index.d.ts +1 -0
- package/dist/documents/index.js +1 -0
- package/dist/documents/journal-hook.d.ts +6 -0
- package/dist/documents/journal-hook.js +16 -0
- package/dist/documents/sync/activate-integration.test.d.ts +1 -0
- package/dist/documents/sync/activate-integration.test.js +37 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
- package/dist/documents/sync/conflicts.d.ts +30 -0
- package/dist/documents/sync/conflicts.js +77 -0
- package/dist/documents/sync/conflicts.test.d.ts +1 -0
- package/dist/documents/sync/conflicts.test.js +71 -0
- package/dist/documents/sync/engine.d.ts +19 -0
- package/dist/documents/sync/engine.js +188 -0
- package/dist/documents/sync/engine.test.d.ts +1 -0
- package/dist/documents/sync/engine.test.js +169 -0
- package/dist/documents/sync/handle.d.ts +11 -0
- package/dist/documents/sync/handle.js +79 -0
- package/dist/documents/sync/handle.test.d.ts +1 -0
- package/dist/documents/sync/handle.test.js +56 -0
- package/dist/documents/sync/hash.d.ts +1 -0
- package/dist/documents/sync/hash.js +13 -0
- package/dist/documents/sync/hash.test.d.ts +1 -0
- package/dist/documents/sync/hash.test.js +20 -0
- package/dist/documents/sync/index.d.ts +6 -0
- package/dist/documents/sync/index.js +12 -0
- package/dist/documents/sync/journal.d.ts +30 -0
- package/dist/documents/sync/journal.js +179 -0
- package/dist/documents/sync/journal.test.d.ts +1 -0
- package/dist/documents/sync/journal.test.js +87 -0
- package/dist/documents/sync/registry.d.ts +10 -0
- package/dist/documents/sync/registry.js +66 -0
- package/dist/documents/sync/registry.test.d.ts +1 -0
- package/dist/documents/sync/registry.test.js +42 -0
- package/dist/documents/sync/serialization.d.ts +5 -0
- package/dist/documents/sync/serialization.js +24 -0
- package/dist/documents/sync/serialization.test.d.ts +1 -0
- package/dist/documents/sync/serialization.test.js +26 -0
- package/dist/documents/sync/singleton.d.ts +11 -0
- package/dist/documents/sync/singleton.js +26 -0
- package/dist/documents/sync/tombstones.d.ts +19 -0
- package/dist/documents/sync/tombstones.js +58 -0
- package/dist/documents/sync/tombstones.test.d.ts +1 -0
- package/dist/documents/sync/tombstones.test.js +37 -0
- package/dist/documents/sync/types.d.ts +116 -0
- package/dist/documents/sync/types.js +27 -0
- package/dist/documents/sync/write-hook.test.d.ts +1 -0
- package/dist/documents/sync/write-hook.test.js +36 -0
- package/dist/env/client.d.ts +10 -5
- package/dist/env/client.js +12 -4
- package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
- package/dist/layout/LayoutRenderer.browser.test.js +274 -0
- package/dist/layout/LayoutRenderer.svelte +2 -1
- package/dist/layout/LayoutRenderer.test.d.ts +1 -0
- package/dist/layout/LayoutRenderer.test.js +143 -0
- package/dist/layout/SlotContainer.svelte +8 -2
- package/dist/layout/SlotDropZone.svelte +19 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-1-drag-tab-between-groups-moves-a-tab-from-one-tabs-group-to-another-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-2-drag-tab-to-quadrant-creates-a-split-when-dropping-a-tab-on-a-quadrant-drop-zone-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-4-close-policy-removes-closable-tabs--keeps-non-closable--and-awaits-canClose-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
- package/dist/layout/drag.svelte.d.ts +5 -0
- package/dist/layout/drag.svelte.js +15 -0
- package/dist/layout/slotHostPool.svelte.d.ts +16 -1
- package/dist/layout/slotHostPool.svelte.js +123 -5
- package/dist/layout/slotHostPool.test.d.ts +1 -0
- package/dist/layout/slotHostPool.test.js +104 -0
- package/dist/layout/store.svelte.d.ts +22 -0
- package/dist/layout/store.svelte.js +78 -16
- package/dist/layout/tree-walk.d.ts +2 -0
- package/dist/layout/tree-walk.js +1 -1
- package/dist/layout/types.d.ts +5 -0
- package/dist/overlays/float.d.ts +2 -0
- package/dist/overlays/float.js +4 -1
- package/dist/overlays/float.test.js +102 -1
- package/dist/primitives/ResizableSplitter.svelte +2 -0
- package/dist/primitives/TabbedPanel.svelte +4 -0
- package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
- package/dist/registry/installer.d.ts +10 -7
- package/dist/registry/installer.js +39 -35
- package/dist/registry/register.d.ts +17 -0
- package/dist/registry/register.js +22 -0
- package/dist/registry/register.test.d.ts +1 -0
- package/dist/registry/register.test.js +28 -0
- package/dist/shards/activate.svelte.d.ts +6 -0
- package/dist/shards/activate.svelte.js +33 -2
- package/dist/shards/registry.d.ts +4 -0
- package/dist/shards/registry.js +18 -0
- package/dist/shards/types.d.ts +16 -1
- package/dist/shell-shard/Terminal.svelte +140 -33
- package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
- package/dist/shell-shard/auto-relocate.d.ts +12 -0
- package/dist/shell-shard/auto-relocate.js +20 -0
- package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
- package/dist/shell-shard/auto-relocate.test.js +35 -0
- package/dist/shell-shard/dispatch.d.ts +15 -0
- package/dist/shell-shard/dispatch.js +56 -0
- package/dist/shell-shard/modes/builtin.d.ts +5 -0
- package/dist/shell-shard/modes/builtin.js +18 -0
- package/dist/shell-shard/modes/prefs.d.ts +5 -0
- package/dist/shell-shard/modes/prefs.js +31 -0
- package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
- package/dist/shell-shard/modes/prefs.test.js +46 -0
- package/dist/shell-shard/modes/registry.d.ts +7 -0
- package/dist/shell-shard/modes/registry.js +27 -0
- package/dist/shell-shard/modes/registry.test.d.ts +1 -0
- package/dist/shell-shard/modes/registry.test.js +35 -0
- package/dist/shell-shard/modes/types.d.ts +8 -0
- package/dist/shell-shard/modes/types.js +1 -0
- package/dist/shell-shard/protocol.d.ts +6 -0
- package/dist/shell-shard/shellShard.svelte.js +5 -1
- package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
- package/dist/shell-shard/tenant-fs-client.js +44 -0
- package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
- package/dist/shell-shard/tenant-fs-client.test.js +49 -0
- package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
- package/dist/shell-shard/terminal-dispatch.test.js +53 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
- package/dist/shell-shard/toolbar/slots.d.ts +17 -0
- package/dist/shell-shard/toolbar/slots.js +26 -0
- package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
- package/dist/shell-shard/toolbar/slots.test.js +28 -0
- package/dist/shell-shard/verbs/cat.d.ts +2 -0
- package/dist/shell-shard/verbs/cat.js +34 -0
- package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
- package/dist/shell-shard/verbs/cd.test.js +56 -0
- package/dist/shell-shard/verbs/env.d.ts +2 -0
- package/dist/shell-shard/verbs/env.js +14 -0
- package/dist/shell-shard/verbs/index.js +6 -1
- package/dist/shell-shard/verbs/ls.d.ts +2 -0
- package/dist/shell-shard/verbs/ls.js +29 -0
- package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
- package/dist/shell-shard/verbs/ls.test.js +49 -0
- package/dist/shell-shard/verbs/session.d.ts +0 -1
- package/dist/shell-shard/verbs/session.js +58 -26
- package/dist/verbs/types.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type ShellApi } from './registry';
|
|
2
|
+
import type { ShellRole } from './modes/types';
|
|
2
3
|
interface Props {
|
|
3
4
|
shell: ShellApi;
|
|
4
5
|
wsUrl: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
role: ShellRole;
|
|
5
8
|
}
|
|
6
9
|
declare const Terminal: import("svelte").Component<Props, {}, "">;
|
|
7
10
|
type Terminal = ReturnType<typeof Terminal>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TenantFsClient } from './tenant-fs-client';
|
|
2
|
+
export interface RelocateInput {
|
|
3
|
+
modeAutoRelocate: boolean;
|
|
4
|
+
focusLocked: boolean;
|
|
5
|
+
focusedShardId: string | null;
|
|
6
|
+
currentShardId: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RelocateEffect {
|
|
9
|
+
kind: 'noop' | 'relocate';
|
|
10
|
+
path?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function computeRelocate(input: RelocateInput, fs: TenantFsClient): Promise<RelocateEffect>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export async function computeRelocate(input, fs) {
|
|
2
|
+
if (!input.modeAutoRelocate)
|
|
3
|
+
return { kind: 'noop' };
|
|
4
|
+
if (input.focusLocked)
|
|
5
|
+
return { kind: 'noop' };
|
|
6
|
+
if (input.focusedShardId === null)
|
|
7
|
+
return { kind: 'noop' };
|
|
8
|
+
if (input.focusedShardId === input.currentShardId)
|
|
9
|
+
return { kind: 'noop' };
|
|
10
|
+
const path = input.focusedShardId;
|
|
11
|
+
try {
|
|
12
|
+
const s = await fs.stat(path);
|
|
13
|
+
if (s.kind !== 'dir')
|
|
14
|
+
return { kind: 'noop' };
|
|
15
|
+
}
|
|
16
|
+
catch (_a) {
|
|
17
|
+
return { kind: 'noop' };
|
|
18
|
+
}
|
|
19
|
+
return { kind: 'relocate', path };
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { computeRelocate } from './auto-relocate';
|
|
3
|
+
const statOk = { list: vi.fn(), read: vi.fn(), stat: async () => ({ name: 'notes', kind: 'dir', size: 0, mtime: 0 }) };
|
|
4
|
+
const statMissing = { list: vi.fn(), read: vi.fn(), stat: async () => { throw new Error('not found'); } };
|
|
5
|
+
const statFile = { list: vi.fn(), read: vi.fn(), stat: async () => ({ name: 'notes', kind: 'file', size: 0, mtime: 0 }) };
|
|
6
|
+
describe('computeRelocate', () => {
|
|
7
|
+
it('noop when mode disables auto-relocate', async () => {
|
|
8
|
+
const r = await computeRelocate({ modeAutoRelocate: false, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
|
|
9
|
+
expect(r.kind).toBe('noop');
|
|
10
|
+
});
|
|
11
|
+
it('noop when focus-lock is on', async () => {
|
|
12
|
+
const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: true, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
|
|
13
|
+
expect(r.kind).toBe('noop');
|
|
14
|
+
});
|
|
15
|
+
it('noop when nothing relevant is focused', async () => {
|
|
16
|
+
const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: null, currentShardId: 'shell' }, statOk);
|
|
17
|
+
expect(r.kind).toBe('noop');
|
|
18
|
+
});
|
|
19
|
+
it('noop when focusing the shell itself', async () => {
|
|
20
|
+
const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'shell', currentShardId: 'shell' }, statOk);
|
|
21
|
+
expect(r.kind).toBe('noop');
|
|
22
|
+
});
|
|
23
|
+
it('noop when target documents folder does not exist', async () => {
|
|
24
|
+
const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statMissing);
|
|
25
|
+
expect(r.kind).toBe('noop');
|
|
26
|
+
});
|
|
27
|
+
it('noop when target is a file, not a directory', async () => {
|
|
28
|
+
const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statFile);
|
|
29
|
+
expect(r.kind).toBe('noop');
|
|
30
|
+
});
|
|
31
|
+
it('relocates to the shard id path on happy path', async () => {
|
|
32
|
+
const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
|
|
33
|
+
expect(r).toEqual({ kind: 'relocate', path: 'notes' });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VerbRegistry, ShellApi } from './registry';
|
|
2
|
+
import type { Scrollback } from './scrollback.svelte';
|
|
3
|
+
import type { SessionClient } from './session-client.svelte';
|
|
4
|
+
import type { TenantFsClient } from './tenant-fs-client';
|
|
5
|
+
import type { ShellMode } from './modes/types';
|
|
6
|
+
export interface DispatchDeps {
|
|
7
|
+
mode: () => ShellMode;
|
|
8
|
+
resolver: VerbRegistry;
|
|
9
|
+
scrollback: Scrollback;
|
|
10
|
+
session: SessionClient;
|
|
11
|
+
shell: ShellApi;
|
|
12
|
+
fs: TenantFsClient;
|
|
13
|
+
cwd: () => string;
|
|
14
|
+
}
|
|
15
|
+
export declare function makeDispatch(deps: DispatchDeps): (line: string) => Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* makeDispatch — mode-aware verb dispatch factory for Terminal.svelte.
|
|
3
|
+
*
|
|
4
|
+
* Pure function (no Svelte reactivity) so it can be unit-tested independently.
|
|
5
|
+
* The mode is passed as a getter so the dispatch closure always sees the
|
|
6
|
+
* current mode without being reconstructed on every mode change.
|
|
7
|
+
*/
|
|
8
|
+
export function makeDispatch(deps) {
|
|
9
|
+
return async function dispatch(line) {
|
|
10
|
+
var _a;
|
|
11
|
+
const mode = deps.mode();
|
|
12
|
+
deps.session.history.push(line);
|
|
13
|
+
// User-mode $ escape: block server-shell access
|
|
14
|
+
if (mode.transport === 'none' && line.trimStart().startsWith('$ ')) {
|
|
15
|
+
deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
|
|
16
|
+
deps.scrollback.push({ kind: 'status', text: 'shell: server shell not available in user mode', level: 'error', ts: Date.now() });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const resolution = deps.resolver.resolve(line);
|
|
20
|
+
if (resolution.kind === 'local') {
|
|
21
|
+
// Log locally-dispatched verbs for shared history (ws only)
|
|
22
|
+
if (mode.transport === 'ws') {
|
|
23
|
+
deps.session.send({ t: 'history-log', line });
|
|
24
|
+
}
|
|
25
|
+
deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
|
|
26
|
+
try {
|
|
27
|
+
await resolution.verb.run({
|
|
28
|
+
shell: deps.shell,
|
|
29
|
+
scrollback: deps.scrollback,
|
|
30
|
+
session: deps.session,
|
|
31
|
+
cwd: deps.cwd(),
|
|
32
|
+
dispatch,
|
|
33
|
+
fs: deps.fs,
|
|
34
|
+
}, resolution.args);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
deps.scrollback.push({
|
|
38
|
+
kind: 'status',
|
|
39
|
+
text: `shell: verb ${resolution.verb.name} threw — ${err.message}`,
|
|
40
|
+
level: 'error',
|
|
41
|
+
ts: Date.now(),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// forward path
|
|
47
|
+
if (mode.transport === 'ws') {
|
|
48
|
+
deps.session.send({ t: 'submit', line: resolution.line });
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const firstToken = (_a = resolution.line.split(/\s+/)[0]) !== null && _a !== void 0 ? _a : '';
|
|
52
|
+
deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
|
|
53
|
+
deps.scrollback.push({ kind: 'status', text: `unknown verb: ${firstToken}`, level: 'error', ts: Date.now() });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ShellModeRegistry } from './registry';
|
|
2
|
+
export const DEV_MODE = {
|
|
3
|
+
id: 'dev',
|
|
4
|
+
label: 'Dev',
|
|
5
|
+
requiresRole: 'admin',
|
|
6
|
+
transport: 'ws',
|
|
7
|
+
autoRelocate: false,
|
|
8
|
+
};
|
|
9
|
+
export const USER_MODE = {
|
|
10
|
+
id: 'user',
|
|
11
|
+
label: 'User',
|
|
12
|
+
transport: 'none',
|
|
13
|
+
autoRelocate: true,
|
|
14
|
+
};
|
|
15
|
+
export function registerBuiltinModes(reg) {
|
|
16
|
+
reg.register(DEV_MODE);
|
|
17
|
+
reg.register(USER_MODE);
|
|
18
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ShellMode, ShellRole } from './types';
|
|
2
|
+
import type { ShellModeRegistry } from './registry';
|
|
3
|
+
export declare function readLastMode(userId: string): string | null;
|
|
4
|
+
export declare function writeLastMode(userId: string, modeId: string): void;
|
|
5
|
+
export declare function resolveInitialMode(reg: ShellModeRegistry, userId: string, role: ShellRole): ShellMode;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function key(userId) {
|
|
2
|
+
return `sh3.shell.lastMode.${userId}`;
|
|
3
|
+
}
|
|
4
|
+
export function readLastMode(userId) {
|
|
5
|
+
var _a, _b;
|
|
6
|
+
try {
|
|
7
|
+
return (_b = (_a = globalThis.localStorage) === null || _a === void 0 ? void 0 : _a.getItem(key(userId))) !== null && _b !== void 0 ? _b : null;
|
|
8
|
+
}
|
|
9
|
+
catch (_c) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function writeLastMode(userId, modeId) {
|
|
14
|
+
var _a;
|
|
15
|
+
try {
|
|
16
|
+
(_a = globalThis.localStorage) === null || _a === void 0 ? void 0 : _a.setItem(key(userId), modeId);
|
|
17
|
+
}
|
|
18
|
+
catch (_b) {
|
|
19
|
+
// Non-browser host or storage disabled — persistence is best-effort.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function resolveInitialMode(reg, userId, role) {
|
|
23
|
+
const persisted = readLastMode(userId);
|
|
24
|
+
if (persisted) {
|
|
25
|
+
const m = reg.get(persisted);
|
|
26
|
+
if (m && (!m.requiresRole || m.requiresRole === role))
|
|
27
|
+
return m;
|
|
28
|
+
}
|
|
29
|
+
const fallback = role === 'admin' ? 'dev' : 'user';
|
|
30
|
+
return reg.get(fallback);
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { readLastMode, writeLastMode, resolveInitialMode } from './prefs';
|
|
3
|
+
import { ShellModeRegistry } from './registry';
|
|
4
|
+
import { registerBuiltinModes } from './builtin';
|
|
5
|
+
class MemStorage {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.m = new Map();
|
|
8
|
+
}
|
|
9
|
+
getItem(k) { var _a; return (_a = this.m.get(k)) !== null && _a !== void 0 ? _a : null; }
|
|
10
|
+
setItem(k, v) { this.m.set(k, v); }
|
|
11
|
+
removeItem(k) { this.m.delete(k); }
|
|
12
|
+
}
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
globalThis.localStorage = new MemStorage();
|
|
15
|
+
});
|
|
16
|
+
describe('readLastMode / writeLastMode', () => {
|
|
17
|
+
it('round-trips a mode id for a user', () => {
|
|
18
|
+
writeLastMode('alice', 'user');
|
|
19
|
+
expect(readLastMode('alice')).toBe('user');
|
|
20
|
+
});
|
|
21
|
+
it('returns null when nothing persisted', () => {
|
|
22
|
+
expect(readLastMode('bob')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('resolveInitialMode', () => {
|
|
26
|
+
const reg = new ShellModeRegistry();
|
|
27
|
+
registerBuiltinModes(reg);
|
|
28
|
+
it('admin with no pref → dev', () => {
|
|
29
|
+
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('dev');
|
|
30
|
+
});
|
|
31
|
+
it('user with no pref → user', () => {
|
|
32
|
+
expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('user');
|
|
33
|
+
});
|
|
34
|
+
it('admin with persisted user → user', () => {
|
|
35
|
+
writeLastMode('alice', 'user');
|
|
36
|
+
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('user');
|
|
37
|
+
});
|
|
38
|
+
it('user with persisted dev (not allowed) → falls back to user', () => {
|
|
39
|
+
writeLastMode('alice', 'dev');
|
|
40
|
+
expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('user');
|
|
41
|
+
});
|
|
42
|
+
it('persisted unknown id → role default', () => {
|
|
43
|
+
writeLastMode('alice', 'nonsense');
|
|
44
|
+
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('dev');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
2
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
3
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
4
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
5
|
+
};
|
|
6
|
+
var _ShellModeRegistry_modes;
|
|
7
|
+
export class ShellModeRegistry {
|
|
8
|
+
constructor() {
|
|
9
|
+
_ShellModeRegistry_modes.set(this, new Map());
|
|
10
|
+
}
|
|
11
|
+
register(mode) {
|
|
12
|
+
__classPrivateFieldGet(this, _ShellModeRegistry_modes, "f").set(mode.id, mode);
|
|
13
|
+
}
|
|
14
|
+
get(id) {
|
|
15
|
+
return __classPrivateFieldGet(this, _ShellModeRegistry_modes, "f").get(id);
|
|
16
|
+
}
|
|
17
|
+
list(role) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const m of __classPrivateFieldGet(this, _ShellModeRegistry_modes, "f").values()) {
|
|
20
|
+
if (m.requiresRole && m.requiresRole !== role)
|
|
21
|
+
continue;
|
|
22
|
+
out.push(m);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
_ShellModeRegistry_modes = new WeakMap();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ShellModeRegistry } from './registry';
|
|
3
|
+
const dev = { id: 'dev', label: 'Dev', requiresRole: 'admin', transport: 'ws', autoRelocate: false };
|
|
4
|
+
const user = { id: 'user', label: 'User', transport: 'none', autoRelocate: true };
|
|
5
|
+
const ssh = { id: 'ssh', label: 'SSH', requiresRole: 'admin', transport: 'custom', autoRelocate: false };
|
|
6
|
+
describe('ShellModeRegistry', () => {
|
|
7
|
+
let reg;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
reg = new ShellModeRegistry();
|
|
10
|
+
});
|
|
11
|
+
it('registers and retrieves modes', () => {
|
|
12
|
+
reg.register(dev);
|
|
13
|
+
expect(reg.get('dev')).toEqual(dev);
|
|
14
|
+
});
|
|
15
|
+
it('list(user) excludes admin-only modes', () => {
|
|
16
|
+
reg.register(dev);
|
|
17
|
+
reg.register(user);
|
|
18
|
+
reg.register(ssh);
|
|
19
|
+
const ids = reg.list('user').map((m) => m.id);
|
|
20
|
+
expect(ids).toEqual(['user']);
|
|
21
|
+
});
|
|
22
|
+
it('list(admin) includes all modes', () => {
|
|
23
|
+
reg.register(dev);
|
|
24
|
+
reg.register(user);
|
|
25
|
+
reg.register(ssh);
|
|
26
|
+
const ids = reg.list('admin').map((m) => m.id).sort();
|
|
27
|
+
expect(ids).toEqual(['dev', 'ssh', 'user']);
|
|
28
|
+
});
|
|
29
|
+
it('re-registering same id replaces the mode', () => {
|
|
30
|
+
var _a;
|
|
31
|
+
reg.register(dev);
|
|
32
|
+
reg.register(Object.assign(Object.assign({}, dev), { label: 'Dev+' }));
|
|
33
|
+
expect((_a = reg.get('dev')) === null || _a === void 0 ? void 0 : _a.label).toBe('Dev+');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -23,6 +23,12 @@ export type ClientMessage =
|
|
|
23
23
|
/** Ask the server for its current cwd. */
|
|
24
24
|
| {
|
|
25
25
|
t: 'cwd-query';
|
|
26
|
+
}
|
|
27
|
+
/** Push a cwd change from the frontend (docs tree, file explorer, etc.).
|
|
28
|
+
* Server resolves `path` relative to current cwd, validates, broadcasts. */
|
|
29
|
+
| {
|
|
30
|
+
t: 'setCwd';
|
|
31
|
+
path: string;
|
|
26
32
|
};
|
|
27
33
|
export type ServerMessage =
|
|
28
34
|
/** Sent once on successful attach, immediately after hello. */
|
|
@@ -119,12 +119,16 @@ export const shellShard = {
|
|
|
119
119
|
const shell = makeShellApi(ctx);
|
|
120
120
|
const factory = {
|
|
121
121
|
mount(container, _context) {
|
|
122
|
+
var _a;
|
|
122
123
|
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
|
123
124
|
const host = typeof location !== 'undefined' ? location.host : 'localhost';
|
|
124
125
|
const wsUrl = `${proto}://${host}/api/shell/session`;
|
|
126
|
+
const user = getUser();
|
|
127
|
+
const userId = (_a = user === null || user === void 0 ? void 0 : user.id) !== null && _a !== void 0 ? _a : 'guest';
|
|
128
|
+
const role = isAdmin() ? 'admin' : 'user';
|
|
125
129
|
const instance = mount(Terminal, {
|
|
126
130
|
target: container,
|
|
127
|
-
props: { shell, wsUrl },
|
|
131
|
+
props: { shell, wsUrl, userId, role },
|
|
128
132
|
});
|
|
129
133
|
return {
|
|
130
134
|
unmount() {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for /api/fs/*. Read-only today.
|
|
3
|
+
* Shared across all shell modes so verb implementations stay mode-agnostic.
|
|
4
|
+
*/
|
|
5
|
+
export interface FsEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
kind: 'file' | 'dir';
|
|
8
|
+
size: number;
|
|
9
|
+
mtime: number;
|
|
10
|
+
}
|
|
11
|
+
export interface FsStat {
|
|
12
|
+
name: string;
|
|
13
|
+
kind: 'file' | 'dir';
|
|
14
|
+
size: number;
|
|
15
|
+
mtime: number;
|
|
16
|
+
}
|
|
17
|
+
export declare class TenantFsClient {
|
|
18
|
+
#private;
|
|
19
|
+
private readonly base;
|
|
20
|
+
constructor(base?: string);
|
|
21
|
+
list(path: string): Promise<FsEntry[]>;
|
|
22
|
+
stat(path: string): Promise<FsStat>;
|
|
23
|
+
read(path: string): Promise<string>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for /api/fs/*. Read-only today.
|
|
3
|
+
* Shared across all shell modes so verb implementations stay mode-agnostic.
|
|
4
|
+
*/
|
|
5
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
6
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
7
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
8
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
9
|
+
};
|
|
10
|
+
var _TenantFsClient_instances, _TenantFsClient_get;
|
|
11
|
+
export class TenantFsClient {
|
|
12
|
+
constructor(base = '') {
|
|
13
|
+
_TenantFsClient_instances.add(this);
|
|
14
|
+
this.base = base;
|
|
15
|
+
}
|
|
16
|
+
async list(path) {
|
|
17
|
+
const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/list', path);
|
|
18
|
+
const body = await res.json();
|
|
19
|
+
return body.entries;
|
|
20
|
+
}
|
|
21
|
+
async stat(path) {
|
|
22
|
+
const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/stat', path);
|
|
23
|
+
return await res.json();
|
|
24
|
+
}
|
|
25
|
+
async read(path) {
|
|
26
|
+
const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/read', path);
|
|
27
|
+
return await res.text();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
_TenantFsClient_instances = new WeakSet(), _TenantFsClient_get = async function _TenantFsClient_get(route, path) {
|
|
31
|
+
const url = `${this.base}${route}?path=${encodeURIComponent(path)}`;
|
|
32
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
let msg = `HTTP ${res.status}`;
|
|
35
|
+
try {
|
|
36
|
+
const j = await res.json();
|
|
37
|
+
if (j.error)
|
|
38
|
+
msg = j.error;
|
|
39
|
+
}
|
|
40
|
+
catch ( /* non-json body */_a) { /* non-json body */ }
|
|
41
|
+
throw new Error(msg);
|
|
42
|
+
}
|
|
43
|
+
return res;
|
|
44
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { TenantFsClient } from './tenant-fs-client';
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.restoreAllMocks();
|
|
5
|
+
});
|
|
6
|
+
function mockFetchJson(status, body) {
|
|
7
|
+
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(body), {
|
|
8
|
+
status,
|
|
9
|
+
headers: { 'content-type': 'application/json' },
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
describe('TenantFsClient.list', () => {
|
|
13
|
+
it('GETs /api/fs/list with the encoded path', async () => {
|
|
14
|
+
const calls = [];
|
|
15
|
+
globalThis.fetch = vi.fn(async (url) => {
|
|
16
|
+
calls.push(String(url));
|
|
17
|
+
return new Response(JSON.stringify({ entries: [{ name: 'a', kind: 'file', size: 1, mtime: 0 }] }), { status: 200 });
|
|
18
|
+
});
|
|
19
|
+
const c = new TenantFsClient();
|
|
20
|
+
const entries = await c.list('sub dir');
|
|
21
|
+
expect(calls[0]).toContain('/api/fs/list?path=sub%20dir');
|
|
22
|
+
expect(entries).toHaveLength(1);
|
|
23
|
+
});
|
|
24
|
+
it('throws on non-2xx', async () => {
|
|
25
|
+
mockFetchJson(403, { error: 'forbidden' });
|
|
26
|
+
const c = new TenantFsClient();
|
|
27
|
+
await expect(c.list('..')).rejects.toThrow(/forbidden/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('TenantFsClient.stat', () => {
|
|
31
|
+
it('returns kind and size', async () => {
|
|
32
|
+
mockFetchJson(200, { name: 'a.txt', kind: 'file', size: 5, mtime: 0 });
|
|
33
|
+
const c = new TenantFsClient();
|
|
34
|
+
const s = await c.stat('a.txt');
|
|
35
|
+
expect(s).toMatchObject({ kind: 'file', size: 5 });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('TenantFsClient.read', () => {
|
|
39
|
+
it('returns body text on 200', async () => {
|
|
40
|
+
globalThis.fetch = vi.fn(async () => new Response('hello', { status: 200 }));
|
|
41
|
+
const c = new TenantFsClient();
|
|
42
|
+
expect(await c.read('a.txt')).toBe('hello');
|
|
43
|
+
});
|
|
44
|
+
it('throws structured error on 413', async () => {
|
|
45
|
+
mockFetchJson(413, { error: 'file too large' });
|
|
46
|
+
const c = new TenantFsClient();
|
|
47
|
+
await expect(c.read('big.bin')).rejects.toThrow(/too large/);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { makeDispatch } from './dispatch';
|
|
3
|
+
function scaffold(modeId) {
|
|
4
|
+
const sent = [];
|
|
5
|
+
const pushed = [];
|
|
6
|
+
const mode = modeId === 'dev'
|
|
7
|
+
? { id: 'dev', label: 'Dev', transport: 'ws', autoRelocate: false, requiresRole: 'admin' }
|
|
8
|
+
: { id: 'user', label: 'User', transport: 'none', autoRelocate: true };
|
|
9
|
+
const scrollback = { push: (e) => pushed.push(e) };
|
|
10
|
+
const session = {
|
|
11
|
+
history: { push: vi.fn() },
|
|
12
|
+
send: (m) => sent.push(m),
|
|
13
|
+
cwd: '/',
|
|
14
|
+
};
|
|
15
|
+
const fs = {};
|
|
16
|
+
const shell = {};
|
|
17
|
+
const resolver = {
|
|
18
|
+
resolve: (line) => line.startsWith('pwd')
|
|
19
|
+
? { kind: 'local', verb: { name: 'pwd', run: async () => { } }, args: [], line }
|
|
20
|
+
: { kind: 'forward', line },
|
|
21
|
+
};
|
|
22
|
+
const dispatch = makeDispatch({
|
|
23
|
+
mode: () => mode,
|
|
24
|
+
resolver,
|
|
25
|
+
scrollback,
|
|
26
|
+
session,
|
|
27
|
+
shell,
|
|
28
|
+
fs,
|
|
29
|
+
cwd: () => '/',
|
|
30
|
+
});
|
|
31
|
+
return { dispatch, sent, pushed };
|
|
32
|
+
}
|
|
33
|
+
describe('dispatch — user mode', () => {
|
|
34
|
+
it('unknown verbs print error, do not send', async () => {
|
|
35
|
+
const { dispatch, sent, pushed } = scaffold('user');
|
|
36
|
+
await dispatch('foo');
|
|
37
|
+
expect(sent).toEqual([]);
|
|
38
|
+
expect(pushed.some((e) => e.kind === 'status' && /unknown verb/.test(e.text))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('$ escape prints error', async () => {
|
|
41
|
+
const { dispatch, sent, pushed } = scaffold('user');
|
|
42
|
+
await dispatch('$ ls');
|
|
43
|
+
expect(sent).toEqual([]);
|
|
44
|
+
expect(pushed.some((e) => e.kind === 'status' && /server shell not available/.test(e.text))).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('dispatch — dev mode', () => {
|
|
48
|
+
it('forwards unknown verbs to server', async () => {
|
|
49
|
+
const { dispatch, sent } = scaffold('dev');
|
|
50
|
+
await dispatch('foo');
|
|
51
|
+
expect(sent.some((m) => m.t === 'submit' && m.line === 'foo')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ToolbarSlotRegistry, ShellSlotCtx } from './slots';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
registry: ToolbarSlotRegistry;
|
|
6
|
+
ctx: ShellSlotCtx;
|
|
7
|
+
expanded: boolean;
|
|
8
|
+
onToggle: () => void;
|
|
9
|
+
slotProps?: Record<string, Record<string, unknown>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { registry, ctx, expanded, onToggle, slotProps = {} }: Props = $props();
|
|
13
|
+
|
|
14
|
+
let slots = $derived(registry.list(ctx));
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="toolbar" class:collapsed={!expanded}>
|
|
18
|
+
<button class="toolbar-toggle" onclick={onToggle} title={expanded ? 'Collapse toolbar' : 'Expand toolbar'}>
|
|
19
|
+
{expanded ? '▲' : '▼'}
|
|
20
|
+
</button>
|
|
21
|
+
{#if expanded}
|
|
22
|
+
<div class="toolbar-slots">
|
|
23
|
+
{#each slots as s (s.id)}
|
|
24
|
+
{@const Slot = s.component}
|
|
25
|
+
<Slot {...(slotProps[s.id] ?? {})} />
|
|
26
|
+
{/each}
|
|
27
|
+
</div>
|
|
28
|
+
{/if}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.toolbar {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 6px;
|
|
36
|
+
padding: 2px 6px;
|
|
37
|
+
background: var(--shell-toolbar-bg, #1a1a1a);
|
|
38
|
+
border-bottom: 1px solid var(--shell-border, #333);
|
|
39
|
+
flex-shrink: 0;
|
|
40
|
+
min-height: 24px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.toolbar-toggle {
|
|
44
|
+
background: none;
|
|
45
|
+
border: none;
|
|
46
|
+
color: var(--shell-fg-dim, #888);
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
font-size: 0.7em;
|
|
49
|
+
padding: 0 2px;
|
|
50
|
+
line-height: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.toolbar-toggle:hover {
|
|
54
|
+
color: var(--shell-fg, #ddd);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.toolbar-slots {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 8px;
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolbarSlotRegistry, ShellSlotCtx } from './slots';
|
|
2
|
+
interface Props {
|
|
3
|
+
registry: ToolbarSlotRegistry;
|
|
4
|
+
ctx: ShellSlotCtx;
|
|
5
|
+
expanded: boolean;
|
|
6
|
+
onToggle: () => void;
|
|
7
|
+
slotProps?: Record<string, Record<string, unknown>>;
|
|
8
|
+
}
|
|
9
|
+
declare const Toolbar: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type Toolbar = ReturnType<typeof Toolbar>;
|
|
11
|
+
export default Toolbar;
|