sh3-core 0.7.5 → 0.8.1
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/api.d.ts +12 -2
- package/dist/api.js +13 -1
- package/dist/app/admin/SystemView.svelte +149 -11
- 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 +21 -1
- package/dist/apps/lifecycle.test.js +50 -1
- package/dist/apps/types.d.ts +7 -2
- package/dist/createShell.d.ts +2 -0
- package/dist/createShell.js +9 -7
- package/dist/documents/backends.d.ts +8 -0
- package/dist/documents/backends.js +87 -0
- package/dist/documents/backends.test.d.ts +1 -0
- package/dist/documents/backends.test.js +33 -0
- package/dist/documents/browse.d.ts +12 -0
- package/dist/documents/browse.js +19 -0
- package/dist/documents/browse.test.d.ts +1 -0
- package/dist/documents/browse.test.js +41 -0
- package/dist/documents/handle.js +5 -0
- package/dist/documents/http-backend.d.ts +4 -0
- package/dist/documents/http-backend.js +14 -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 +5 -0
- package/dist/documents/sync/index.js +10 -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/observer.d.ts +3 -0
- package/dist/documents/sync/observer.js +45 -0
- package/dist/documents/sync/registry.d.ts +13 -0
- package/dist/documents/sync/registry.js +73 -0
- package/dist/documents/sync/registry.test.d.ts +1 -0
- package/dist/documents/sync/registry.test.js +53 -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/documents/types.d.ts +18 -0
- package/dist/documents/types.js +6 -1
- package/dist/env/client.d.ts +10 -5
- package/dist/env/client.js +12 -4
- package/dist/layout/inspection.d.ts +17 -0
- package/dist/layout/inspection.js +53 -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-browse.test.d.ts +1 -0
- package/dist/shards/activate-browse.test.js +36 -0
- package/dist/shards/activate-sync-registry.test.d.ts +1 -0
- package/dist/shards/activate-sync-registry.test.js +42 -0
- package/dist/shards/activate-tenantid.test.d.ts +1 -0
- package/dist/shards/activate-tenantid.test.js +21 -0
- package/dist/shards/activate.svelte.d.ts +12 -0
- package/dist/shards/activate.svelte.js +53 -2
- package/dist/shards/types.d.ts +43 -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/manifest.js +1 -1
- 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 +57 -5
- 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 +9 -2
- 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/shell-shard/verbs/views.d.ts +2 -0
- package/dist/shell-shard/verbs/views.js +103 -2
- package/dist/verbs/types.d.ts +21 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@ export const manifest = {
|
|
|
3
3
|
id: 'shell',
|
|
4
4
|
label: 'Shell',
|
|
5
5
|
version: VERSION,
|
|
6
|
-
views: [{ id: 'shell:terminal', label: 'Shell' }],
|
|
6
|
+
views: [{ id: 'shell:terminal', label: 'Shell', standalone: true }],
|
|
7
7
|
// serverBundle intentionally omitted — this shard is a framework built-in
|
|
8
8
|
// and is statically mounted at sh3-server boot. The existing contract in
|
|
9
9
|
// sh3-core/src/shards/types.ts documents that framework-shipped shards do
|
|
@@ -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. */
|
|
@@ -19,7 +19,9 @@ import { registerV1Verbs } from './verbs';
|
|
|
19
19
|
import { listRegisteredApps, getActiveApp } from '../apps/registry.svelte';
|
|
20
20
|
import { launchApp } from '../apps/lifecycle';
|
|
21
21
|
import { registeredShards } from '../shards/activate.svelte';
|
|
22
|
-
import { inspectActiveLayout, focusView, closeTab } from '../layout/inspection';
|
|
22
|
+
import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout } from '../layout/inspection';
|
|
23
|
+
import { floatManager } from '../overlays/float';
|
|
24
|
+
import { listStandaloneViews } from '../shards/activate.svelte';
|
|
23
25
|
import { getUser, isAdmin } from '../auth/index';
|
|
24
26
|
/** Walk a layout tree and collect all tab entries (slotId + viewId + label). */
|
|
25
27
|
function collectTabEntries(node) {
|
|
@@ -76,16 +78,62 @@ function makeShellApi(_ctx) {
|
|
|
76
78
|
return [];
|
|
77
79
|
}
|
|
78
80
|
},
|
|
79
|
-
// → layout/inspection: focusView(viewId)
|
|
81
|
+
// → layout/inspection: focusView(viewId). Falls back to dockIntoActiveLayout
|
|
82
|
+
// for standalone views that aren't mounted yet — this is the single
|
|
83
|
+
// "summon" entry point wired behind the `open` verb.
|
|
80
84
|
openViewInCurrentLayout(viewId) {
|
|
81
85
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
if (focusView(viewId))
|
|
87
|
+
return { ok: true };
|
|
88
|
+
const standalone = listStandaloneViews().find((v) => v.viewId === viewId);
|
|
89
|
+
if (standalone) {
|
|
90
|
+
const slotId = `standalone:${viewId}:${Date.now()}`;
|
|
91
|
+
const ok = dockIntoActiveLayout({ slotId, viewId, label: standalone.label });
|
|
92
|
+
return ok ? { ok: true } : { ok: false, error: `could not dock "${viewId}" — no available slot` };
|
|
93
|
+
}
|
|
94
|
+
return { ok: false, error: `view "${viewId}" not found in current layout` };
|
|
84
95
|
}
|
|
85
96
|
catch (err) {
|
|
86
97
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
87
98
|
}
|
|
88
99
|
},
|
|
100
|
+
// → shards/activate.svelte: listStandaloneViews() walks activeShards
|
|
101
|
+
listStandaloneViews() {
|
|
102
|
+
return listStandaloneViews();
|
|
103
|
+
},
|
|
104
|
+
// → layout/inspection: popoutView(slotId) returns floatId | null
|
|
105
|
+
popoutSlot(slotId) {
|
|
106
|
+
try {
|
|
107
|
+
const floatId = popoutView(slotId);
|
|
108
|
+
return floatId ? { ok: true, floatId } : { ok: false, error: `slot "${slotId}" not found in docked tree` };
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
// → layout/inspection: dockFloat(floatId) returns boolean
|
|
115
|
+
dockFloat(floatId) {
|
|
116
|
+
try {
|
|
117
|
+
const ok = dockFloat(floatId);
|
|
118
|
+
return ok ? { ok: true } : { ok: false, error: `float "${floatId}" not found or has no dockable content` };
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
// → overlays/float: floatManager.list() returns FloatEntry[]
|
|
125
|
+
listFloats() {
|
|
126
|
+
return floatManager.list().map((f) => {
|
|
127
|
+
var _a, _b, _c, _d, _e;
|
|
128
|
+
const tabs = f.content.type === 'tabs' ? f.content : null;
|
|
129
|
+
const active = tabs ? (_b = tabs.tabs[(_a = tabs.activeTab) !== null && _a !== void 0 ? _a : 0]) !== null && _b !== void 0 ? _b : tabs.tabs[0] : null;
|
|
130
|
+
return {
|
|
131
|
+
floatId: f.id,
|
|
132
|
+
viewId: (_c = active === null || active === void 0 ? void 0 : active.viewId) !== null && _c !== void 0 ? _c : null,
|
|
133
|
+
label: (_e = (_d = f.title) !== null && _d !== void 0 ? _d : active === null || active === void 0 ? void 0 : active.label) !== null && _e !== void 0 ? _e : f.id,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
},
|
|
89
137
|
// → layout/inspection: closeTab(slotId) is async (guarded close).
|
|
90
138
|
// Fire-and-forget; the tab disappears asynchronously. ShellApi stays sync.
|
|
91
139
|
closeSlot(slotId) {
|
|
@@ -119,12 +167,16 @@ export const shellShard = {
|
|
|
119
167
|
const shell = makeShellApi(ctx);
|
|
120
168
|
const factory = {
|
|
121
169
|
mount(container, _context) {
|
|
170
|
+
var _a;
|
|
122
171
|
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
|
123
172
|
const host = typeof location !== 'undefined' ? location.host : 'localhost';
|
|
124
173
|
const wsUrl = `${proto}://${host}/api/shell/session`;
|
|
174
|
+
const user = getUser();
|
|
175
|
+
const userId = (_a = user === null || user === void 0 ? void 0 : user.id) !== null && _a !== void 0 ? _a : 'guest';
|
|
176
|
+
const role = isAdmin() ? 'admin' : 'user';
|
|
125
177
|
const instance = mount(Terminal, {
|
|
126
178
|
target: container,
|
|
127
|
-
props: { shell, wsUrl },
|
|
179
|
+
props: { shell, wsUrl, userId, role },
|
|
128
180
|
});
|
|
129
181
|
return {
|
|
130
182
|
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;
|