moqi-tui 0.2.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/LICENSE +21 -0
- package/README.md +782 -0
- package/bin/moqi.mjs +40 -0
- package/cordis.patch.yml +41 -0
- package/lib/cross-find.js +217 -0
- package/lib/file-index.js +121 -0
- package/lib/fleet-sources.js +114 -0
- package/lib/index.js +3999 -0
- package/lib/persist.js +194 -0
- package/lib/plugins.js +371 -0
- package/lib/presence.js +144 -0
- package/lib/rename.js +35 -0
- package/lib/rewind.js +94 -0
- package/lib/sessions-store.js +134 -0
- package/lib/startup.js +92 -0
- package/lib/tui/atfile.js +154 -0
- package/lib/tui/export.js +48 -0
- package/lib/tui/fleet.js +346 -0
- package/lib/tui/i18n.js +201 -0
- package/lib/tui/jobs.js +65 -0
- package/lib/tui/keys.js +205 -0
- package/lib/tui/markdown.js +368 -0
- package/lib/tui/mcp.js +95 -0
- package/lib/tui/panels.js +231 -0
- package/lib/tui/screen.js +156 -0
- package/lib/tui/state.js +502 -0
- package/lib/tui/stream.js +109 -0
- package/lib/tui/text.js +173 -0
- package/lib/tui/theme.js +183 -0
- package/lib/tui/themes.js +153 -0
- package/lib/tui/tooldetail.js +140 -0
- package/lib/tui/view.js +830 -0
- package/lib/tui/vim.js +222 -0
- package/lib/tui-host-core.js +141 -0
- package/lib/tui-host.js +48 -0
- package/lib/types/cross-find.d.ts +66 -0
- package/lib/types/file-index.d.ts +34 -0
- package/lib/types/fleet-sources.d.ts +34 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/persist.d.ts +116 -0
- package/lib/types/plugins.d.ts +218 -0
- package/lib/types/presence.d.ts +48 -0
- package/lib/types/rename.d.ts +32 -0
- package/lib/types/rewind.d.ts +75 -0
- package/lib/types/sessions-store.d.ts +46 -0
- package/lib/types/startup.d.ts +45 -0
- package/lib/types/tui/atfile.d.ts +90 -0
- package/lib/types/tui/export.d.ts +18 -0
- package/lib/types/tui/fleet.d.ts +209 -0
- package/lib/types/tui/i18n.d.ts +34 -0
- package/lib/types/tui/jobs.d.ts +28 -0
- package/lib/types/tui/keys.d.ts +52 -0
- package/lib/types/tui/markdown.d.ts +14 -0
- package/lib/types/tui/mcp.d.ts +34 -0
- package/lib/types/tui/panels.d.ts +125 -0
- package/lib/types/tui/screen.d.ts +79 -0
- package/lib/types/tui/state.d.ts +323 -0
- package/lib/types/tui/stream.d.ts +78 -0
- package/lib/types/tui/text.d.ts +28 -0
- package/lib/types/tui/theme.d.ts +87 -0
- package/lib/types/tui/themes.d.ts +70 -0
- package/lib/types/tui/tooldetail.d.ts +45 -0
- package/lib/types/tui/view.d.ts +163 -0
- package/lib/types/tui/vim.d.ts +64 -0
- package/lib/types/tui-host-core.d.ts +62 -0
- package/lib/types/tui-host.d.ts +42 -0
- package/lib/types/version.d.ts +8 -0
- package/lib/types/voice.d.ts +227 -0
- package/lib/version.js +32 -0
- package/lib/voice.js +405 -0
- package/package.json +119 -0
- package/scripts/harness-root.mjs +88 -0
- package/scripts/install-profile.mjs +133 -0
package/bin/moqi.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `moqi` launcher: install (or refresh) the `tui` profile, then
|
|
4
|
+
* hand off to `dsh --profile tui`.
|
|
5
|
+
*
|
|
6
|
+
* Works identically from a git checkout and a global npm install — the
|
|
7
|
+
* package root is resolved from this file's own location, and the profile's
|
|
8
|
+
* `link:` dependency points at whichever copy is running.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from 'node:child_process'
|
|
11
|
+
import { dirname, resolve } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const root = resolve(here, '..')
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2)
|
|
18
|
+
const command = args[0] === 'install' ? 'install' : 'run'
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// The installer is plain Node with no build step, so it runs from source.
|
|
22
|
+
execFileSync(process.execPath, [resolve(root, 'scripts', 'install-profile.mjs'), 'tui'], {
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
})
|
|
25
|
+
} catch {
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (command === 'install') {
|
|
30
|
+
console.log('moqi: profile installed — start it with `dsh --profile tui`')
|
|
31
|
+
process.exit(0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Hand the terminal over to dsh itself; the exit code is the app's.
|
|
35
|
+
try {
|
|
36
|
+
const dsh = process.env['DSH_BIN'] ?? 'dsh'
|
|
37
|
+
execFileSync(dsh, ['--profile', 'tui', ...args], { stdio: 'inherit' })
|
|
38
|
+
} catch (error) {
|
|
39
|
+
process.exitCode = typeof error?.status === 'number' ? error.status : 1
|
|
40
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# The moqi bundle patch: an interactive terminal app over dsh-base.
|
|
2
|
+
#
|
|
3
|
+
# It mounts no Host, HTTP server, or browser plugin — the terminal is the only
|
|
4
|
+
# surface. A startup provider parses this app's own flags out of the shared
|
|
5
|
+
# cmdline snapshot, and the app row consumes them lazily the way the shipped
|
|
6
|
+
# headless bundle does.
|
|
7
|
+
|
|
8
|
+
- id: system-prompt
|
|
9
|
+
config:
|
|
10
|
+
personaSuffix: Your working directory is {{cwd}}.
|
|
11
|
+
personaPrefix: >-
|
|
12
|
+
You are a coding agent powered by the {{model}} model.
|
|
13
|
+
|
|
14
|
+
- id: tools
|
|
15
|
+
config:
|
|
16
|
+
mode: !!js process.env.DSH_TOOLS_MODE
|
|
17
|
+
|
|
18
|
+
- insert:
|
|
19
|
+
- id: tui-startup
|
|
20
|
+
name: 'moqi-tui/startup'
|
|
21
|
+
|
|
22
|
+
- id: tui-app
|
|
23
|
+
name: 'moqi-tui'
|
|
24
|
+
inject: [tuiStartup]
|
|
25
|
+
config:
|
|
26
|
+
resumeSessionId: !!js ctx.tuiStartup.resumeSessionId
|
|
27
|
+
model: !!js ctx.tuiStartup.model
|
|
28
|
+
thinking: !!js ctx.tuiStartup.thinking
|
|
29
|
+
contextLimit: !!js ctx.tuiStartup.contextLimit
|
|
30
|
+
mouse: !!js ctx.tuiStartup.mouse
|
|
31
|
+
bell: !!js ctx.tuiStartup.bell
|
|
32
|
+
peers: !!js ctx.tuiStartup.peers
|
|
33
|
+
restore: !!js ctx.tuiStartup.restore
|
|
34
|
+
voiceModel: !!js ctx.tuiStartup.voiceModel
|
|
35
|
+
voiceBin: !!js ctx.tuiStartup.voiceBin
|
|
36
|
+
dispatchProfile: !!js ctx.tuiStartup.dispatchProfile
|
|
37
|
+
|
|
38
|
+
# The terminal owns the screen; a hot reload repainting underneath it would
|
|
39
|
+
# corrupt the alternate buffer.
|
|
40
|
+
- id: hmr
|
|
41
|
+
disabled: true
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-session search: `/find --sessions`.
|
|
3
|
+
*
|
|
4
|
+
* Stored sessions live under `$DSH_HOME/sessions/<projectKey>/<sessionId>/` as
|
|
5
|
+
* either a plain `.jsonl` log or a zstd-compressed one. This module walks them,
|
|
6
|
+
* reads whichever form it finds, and returns the matching lines with enough
|
|
7
|
+
* context to recognize the conversation — no index, no cache, and nothing
|
|
8
|
+
* written, so it can never corrupt a log another process is appending to.
|
|
9
|
+
*
|
|
10
|
+
* The zstd decoder is feature-detected: on a Node without it, compressed logs
|
|
11
|
+
* are skipped and reported as such rather than silently ignored.
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
17
|
+
export const DEFAULT_LIMITS = {
|
|
18
|
+
maxSessions: 400,
|
|
19
|
+
maxHits: 80,
|
|
20
|
+
maxBytes: 4_000_000,
|
|
21
|
+
};
|
|
22
|
+
/** Whether this build of Node can decompress a zstd log. */
|
|
23
|
+
export function zstdAvailable() {
|
|
24
|
+
return typeof zstdDecompressSync === 'function';
|
|
25
|
+
}
|
|
26
|
+
/** Extract printable message text from one JSONL record, or `''`. */
|
|
27
|
+
function textOfRecord(raw) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { text: '' };
|
|
34
|
+
}
|
|
35
|
+
const record = parsed;
|
|
36
|
+
const type = String(record.type ?? '');
|
|
37
|
+
const role = type === 'user/message' ? 'user' : type === 'assistant/message' ? 'assistant' : undefined;
|
|
38
|
+
if (role === undefined)
|
|
39
|
+
return { text: '' };
|
|
40
|
+
const blocks = record.data?.message?.content;
|
|
41
|
+
if (!Array.isArray(blocks))
|
|
42
|
+
return { text: '' };
|
|
43
|
+
const text = blocks
|
|
44
|
+
.filter((block) => {
|
|
45
|
+
const candidate = block;
|
|
46
|
+
return candidate.type === 'text' && typeof candidate.text === 'string';
|
|
47
|
+
})
|
|
48
|
+
.map((block) => block.text)
|
|
49
|
+
.join(' ');
|
|
50
|
+
return { text, ...(role === undefined ? {} : { role }) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse a whole JSONL session body into its visible messages.
|
|
54
|
+
*
|
|
55
|
+
* Shared by cross-session search and the fleet preview, so both read a log the
|
|
56
|
+
* same way — including a foreign device's log, which may be an older format
|
|
57
|
+
* whose unknown records are simply skipped.
|
|
58
|
+
*/
|
|
59
|
+
export function parseLogMessages(body) {
|
|
60
|
+
const messages = [];
|
|
61
|
+
for (const raw of body.split('\n')) {
|
|
62
|
+
if (!raw.includes('"'))
|
|
63
|
+
continue;
|
|
64
|
+
const { text, role } = textOfRecord(raw);
|
|
65
|
+
if (text === '' || role === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
messages.push({ role, text });
|
|
68
|
+
}
|
|
69
|
+
return messages;
|
|
70
|
+
}
|
|
71
|
+
/** Whether a buffer carries the zstd frame magic. */
|
|
72
|
+
export function isZstdFrame(bytes) {
|
|
73
|
+
return bytes.length >= 4 && bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f && bytes[3] === 0xfd;
|
|
74
|
+
}
|
|
75
|
+
/** Decode raw log bytes, whichever form they arrived in. */
|
|
76
|
+
export function decodeLogBytes(bytes) {
|
|
77
|
+
if (!isZstdFrame(bytes))
|
|
78
|
+
return Buffer.from(bytes).toString('utf8');
|
|
79
|
+
if (!zstdAvailable())
|
|
80
|
+
return undefined;
|
|
81
|
+
try {
|
|
82
|
+
return zstdDecompressSync(bytes).toString('utf8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Read one log file, decompressing when it is zstd, bounded by `maxBytes`. */
|
|
89
|
+
function readLog(path, maxBytes) {
|
|
90
|
+
try {
|
|
91
|
+
const raw = readFileSync(path);
|
|
92
|
+
if (path.endsWith('.zstd')) {
|
|
93
|
+
if (!zstdAvailable())
|
|
94
|
+
return undefined;
|
|
95
|
+
// A compressed frame cannot be decoded from a prefix, so the whole file
|
|
96
|
+
// is decoded — but a log beyond the byte ceiling is skipped rather than
|
|
97
|
+
// held in memory twice.
|
|
98
|
+
if (raw.byteLength > maxBytes)
|
|
99
|
+
return undefined;
|
|
100
|
+
try {
|
|
101
|
+
return zstdDecompressSync(raw).toString('utf8');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const slice = raw.byteLength > maxBytes ? raw.subarray(0, maxBytes) : raw;
|
|
108
|
+
return slice.toString('utf8');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Session directories under the store root, most recently modified first. */
|
|
115
|
+
function sessionDirs(root) {
|
|
116
|
+
const found = [];
|
|
117
|
+
let projects;
|
|
118
|
+
try {
|
|
119
|
+
projects = readdirSync(root);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
for (const project of projects) {
|
|
125
|
+
const projectDir = join(root, project);
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = readdirSync(projectDir);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const id of entries) {
|
|
134
|
+
if (!id.startsWith('session-'))
|
|
135
|
+
continue;
|
|
136
|
+
const dir = join(projectDir, id);
|
|
137
|
+
try {
|
|
138
|
+
if (!statSync(dir).isDirectory())
|
|
139
|
+
continue;
|
|
140
|
+
found.push({ project, dir, id, mtime: statSync(dir).mtimeMs });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
found.sort((a, b) => b.mtime - a.mtime);
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
/** The log file inside a session directory, whichever format it is. */
|
|
151
|
+
function logFile(dir) {
|
|
152
|
+
let entries;
|
|
153
|
+
try {
|
|
154
|
+
entries = readdirSync(dir);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const zstd = entries.find((name) => name.endsWith('.jsonl.zstd'));
|
|
160
|
+
if (zstd !== undefined)
|
|
161
|
+
return join(dir, zstd);
|
|
162
|
+
const plain = entries.find((name) => name.endsWith('.jsonl'));
|
|
163
|
+
return plain === undefined ? undefined : join(dir, plain);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Search every stored session for a case-insensitive substring.
|
|
167
|
+
*
|
|
168
|
+
* @returns matching lines in session order (newest session first), capped by
|
|
169
|
+
* the limits. Skipped compressed logs on a Node without zstd mean fewer
|
|
170
|
+
* results, never wrong ones.
|
|
171
|
+
*/
|
|
172
|
+
export function searchSessions(root, query, limits = DEFAULT_LIMITS) {
|
|
173
|
+
const needle = query.trim().toLowerCase();
|
|
174
|
+
if (needle === '')
|
|
175
|
+
return { hits: [], scanned: 0, skippedCompressed: 0 };
|
|
176
|
+
const hits = [];
|
|
177
|
+
let scanned = 0;
|
|
178
|
+
let skippedCompressed = 0;
|
|
179
|
+
for (const candidate of sessionDirs(root)) {
|
|
180
|
+
if (scanned >= limits.maxSessions || hits.length >= limits.maxHits)
|
|
181
|
+
break;
|
|
182
|
+
const path = logFile(candidate.dir);
|
|
183
|
+
if (path === undefined)
|
|
184
|
+
continue;
|
|
185
|
+
if (path.endsWith('.zstd') && !zstdAvailable()) {
|
|
186
|
+
skippedCompressed += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const body = readLog(path, limits.maxBytes);
|
|
190
|
+
if (body === undefined)
|
|
191
|
+
continue;
|
|
192
|
+
scanned += 1;
|
|
193
|
+
for (const raw of body.split('\n')) {
|
|
194
|
+
if (hits.length >= limits.maxHits)
|
|
195
|
+
break;
|
|
196
|
+
if (!raw.includes('"'))
|
|
197
|
+
continue;
|
|
198
|
+
const { text, role } = textOfRecord(raw);
|
|
199
|
+
if (text === '')
|
|
200
|
+
continue;
|
|
201
|
+
const context = text.replace(/\s+/g, ' ').trim();
|
|
202
|
+
const index = context.toLowerCase().indexOf(needle);
|
|
203
|
+
if (index === -1)
|
|
204
|
+
continue;
|
|
205
|
+
const at = Math.max(index - 30, 0);
|
|
206
|
+
const snippet = context.slice(at, at + 140);
|
|
207
|
+
hits.push({
|
|
208
|
+
sessionId: candidate.id,
|
|
209
|
+
project: candidate.project.replace(/^--|--$/g, ''),
|
|
210
|
+
line: at > 0 ? `…${snippet}` : snippet,
|
|
211
|
+
...(role === undefined ? {} : { role }),
|
|
212
|
+
path,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { hits, scanned, skippedCompressed };
|
|
217
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bounded, cached walk of the workspace for `@` file completion.
|
|
3
|
+
*
|
|
4
|
+
* The listing is refreshable rather than watched: completion is an
|
|
5
|
+
* interactive nicety, so a few seconds of staleness after a checkout or a
|
|
6
|
+
* build costs nothing, while a filesystem watcher would cost a handle and a
|
|
7
|
+
* failure mode.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { join, relative, sep } from 'node:path';
|
|
12
|
+
import { SKIP_DIRECTORIES } from "./tui/atfile.js";
|
|
13
|
+
/** Hard ceiling on walked entries, so a giant monorepo cannot stall a keystroke. */
|
|
14
|
+
const MAX_ENTRIES = 4000;
|
|
15
|
+
/** How deep the walk descends. */
|
|
16
|
+
const MAX_DEPTH = 8;
|
|
17
|
+
/** Milliseconds a listing stays fresh. */
|
|
18
|
+
const FRESH_MS = 5000;
|
|
19
|
+
/** Files ending here are never offered; nothing good comes of picking one. */
|
|
20
|
+
const SKIP_SUFFIXES = ['.lock', '.min.js', '.map'];
|
|
21
|
+
/** One workspace's file list, recomputed lazily on demand. */
|
|
22
|
+
export class FileIndex {
|
|
23
|
+
root;
|
|
24
|
+
entries = [];
|
|
25
|
+
readAt = 0;
|
|
26
|
+
reading = false;
|
|
27
|
+
constructor(root) {
|
|
28
|
+
this.root = root;
|
|
29
|
+
}
|
|
30
|
+
/** Every workspace-relative file path, oldest acceptable snapshot or fresh. */
|
|
31
|
+
list() {
|
|
32
|
+
if (Date.now() - this.readAt > FRESH_MS && !this.reading)
|
|
33
|
+
this.refresh();
|
|
34
|
+
return this.entries;
|
|
35
|
+
}
|
|
36
|
+
/** Synchronously rebuild the listing. Errors leave the previous snapshot. */
|
|
37
|
+
refresh() {
|
|
38
|
+
if (this.reading)
|
|
39
|
+
return;
|
|
40
|
+
this.reading = true;
|
|
41
|
+
try {
|
|
42
|
+
const found = [];
|
|
43
|
+
this.walk(this.root, 0, found);
|
|
44
|
+
this.entries = found;
|
|
45
|
+
this.readAt = Date.now();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// An unreadable workspace keeps the last good listing (or empty).
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
this.reading = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
walk(directory, depth, found) {
|
|
55
|
+
if (depth >= MAX_DEPTH || found.length >= MAX_ENTRIES)
|
|
56
|
+
return;
|
|
57
|
+
let names;
|
|
58
|
+
try {
|
|
59
|
+
names = readdirSync(directory);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
if (found.length >= MAX_ENTRIES)
|
|
66
|
+
return;
|
|
67
|
+
if (name.startsWith('.') && name !== '.github')
|
|
68
|
+
continue;
|
|
69
|
+
const full = join(directory, name);
|
|
70
|
+
let stats;
|
|
71
|
+
try {
|
|
72
|
+
stats = statSync(full);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (stats.isDirectory()) {
|
|
78
|
+
if (SKIP_DIRECTORIES.has(name))
|
|
79
|
+
continue;
|
|
80
|
+
this.walk(full, depth + 1, found);
|
|
81
|
+
}
|
|
82
|
+
else if (stats.isFile()) {
|
|
83
|
+
if (SKIP_SUFFIXES.some((suffix) => name.endsWith(suffix)))
|
|
84
|
+
continue;
|
|
85
|
+
const rel = relative(this.root, full);
|
|
86
|
+
found.push(sep === '/' ? rel : rel.replaceAll(sep, '/'));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* List one directory for a path-shaped query, relative to the workspace
|
|
92
|
+
* root. `.` and `..` are offered alongside the entries so navigation works
|
|
93
|
+
* the way a shell expects.
|
|
94
|
+
*/
|
|
95
|
+
listDir(relativePath) {
|
|
96
|
+
const base = relativePath === '' ? this.root : join(this.root, relativePath);
|
|
97
|
+
let names;
|
|
98
|
+
try {
|
|
99
|
+
names = readdirSync(base);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { files: [] };
|
|
103
|
+
}
|
|
104
|
+
const prefix = relativePath === '' ? '' : `${relativePath.replaceAll(sep, '/')}/`;
|
|
105
|
+
const files = [];
|
|
106
|
+
for (const name of names.slice(0, MAX_ENTRIES)) {
|
|
107
|
+
if (name.startsWith('.'))
|
|
108
|
+
continue;
|
|
109
|
+
let stats;
|
|
110
|
+
try {
|
|
111
|
+
stats = statSync(join(base, name));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
files.push({ path: `${prefix}${name}`, directory: stats.isDirectory() });
|
|
117
|
+
}
|
|
118
|
+
files.sort((a, b) => Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path));
|
|
119
|
+
return { files };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collecting presence records from this device and its peers.
|
|
3
|
+
*
|
|
4
|
+
* The transport is SSH, for one reason: it is the only channel in this setup
|
|
5
|
+
* that is already authenticated, already encrypted, and already working. The
|
|
6
|
+
* Harness's own web server offers no TLS, no authentication and no origin
|
|
7
|
+
* policy, and binding it off loopback would publish every route on the
|
|
8
|
+
* network — so the overview never does that.
|
|
9
|
+
*
|
|
10
|
+
* Every remote read is one short, non-interactive command. Nothing is
|
|
11
|
+
* installed on the peer beyond dsh itself, and nothing listens anywhere.
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { readPresenceDir } from "./presence.js";
|
|
18
|
+
import { isPresenceRecord, } from "./tui/fleet.js";
|
|
19
|
+
/** How long a peer may take before it is reported unreachable. */
|
|
20
|
+
const SSH_TIMEOUT_MS = 6000;
|
|
21
|
+
/** Resolve this device's Harness home the way the Harness itself does. */
|
|
22
|
+
export function localDshHome(env = process.env) {
|
|
23
|
+
const configured = env['DSH_HOME'];
|
|
24
|
+
if (configured !== undefined && configured.trim() !== '')
|
|
25
|
+
return configured;
|
|
26
|
+
return join(homedir(), '.dsh');
|
|
27
|
+
}
|
|
28
|
+
/** Read this device's records. */
|
|
29
|
+
export function collectLocal(dshHome = localDshHome()) {
|
|
30
|
+
return {
|
|
31
|
+
host: 'local',
|
|
32
|
+
local: true,
|
|
33
|
+
records: readPresenceDir(join(dshHome, 'tui-presence')),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read one peer's records over SSH.
|
|
38
|
+
*
|
|
39
|
+
* `BatchMode=yes` matters: a peer whose key is missing must fail fast with an
|
|
40
|
+
* error in the overview rather than blocking the UI on a password prompt.
|
|
41
|
+
*/
|
|
42
|
+
export async function collectPeer(peer) {
|
|
43
|
+
const home = peer.dshHome ?? '$HOME/.dsh';
|
|
44
|
+
// cat of a glob that matches nothing would fail; guard it in the shell.
|
|
45
|
+
const remote = `d="${home}/tui-presence"; [ -d "$d" ] && cat "$d"/*.json 2>/dev/null || true`;
|
|
46
|
+
try {
|
|
47
|
+
const stdout = await ssh(peer.host, remote);
|
|
48
|
+
return { host: peer.host, local: false, records: parseRecords(stdout) };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
host: peer.host,
|
|
53
|
+
local: false,
|
|
54
|
+
records: [],
|
|
55
|
+
error: describe(error),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Read every device in parallel; one slow peer must not hold up the rest. */
|
|
60
|
+
export async function collectFleet(peers, dshHome = localDshHome()) {
|
|
61
|
+
const remote = await Promise.all(peers.map((peer) => collectPeer(peer)));
|
|
62
|
+
return [collectLocal(dshHome), ...remote];
|
|
63
|
+
}
|
|
64
|
+
/** Parse concatenated JSON records, tolerating whatever else is in the stream. */
|
|
65
|
+
function parseRecords(stdout) {
|
|
66
|
+
const records = [];
|
|
67
|
+
// Records are written one JSON object per file with a trailing newline, so
|
|
68
|
+
// concatenation yields one object per line in practice; fall back to a
|
|
69
|
+
// brace scan if a peer wrote them pretty-printed.
|
|
70
|
+
for (const line of stdout.split('\n')) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (trimmed === '' || !trimmed.startsWith('{'))
|
|
73
|
+
continue;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(trimmed);
|
|
76
|
+
if (isPresenceRecord(parsed))
|
|
77
|
+
records.push(parsed);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Ignore a partial line rather than losing the whole peer.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return records;
|
|
84
|
+
}
|
|
85
|
+
/** Run one non-interactive SSH command. */
|
|
86
|
+
function ssh(host, command) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
execFile('ssh', [
|
|
89
|
+
'-o',
|
|
90
|
+
'BatchMode=yes',
|
|
91
|
+
'-o',
|
|
92
|
+
`ConnectTimeout=${String(Math.ceil(SSH_TIMEOUT_MS / 1000))}`,
|
|
93
|
+
host,
|
|
94
|
+
command,
|
|
95
|
+
], { timeout: SSH_TIMEOUT_MS, encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
96
|
+
if (error !== null) {
|
|
97
|
+
// ssh puts the useful line on stderr; node's own message is the whole
|
|
98
|
+
// command echoed back, which is noise in a one-line overview.
|
|
99
|
+
const detail = String(stderr)
|
|
100
|
+
.split('\n')
|
|
101
|
+
.map((line) => line.replace(/^ssh: /, '').trim())
|
|
102
|
+
.find((line) => line !== '');
|
|
103
|
+
const timedOut = error.code === 'ETIMEDOUT';
|
|
104
|
+
reject(new Error(detail ?? (timedOut ? 'timed out' : 'unreachable')));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
resolve(String(stdout));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** A one-line, human-readable form of anything thrown. */
|
|
112
|
+
function describe(error) {
|
|
113
|
+
return error instanceof Error ? error.message : String(error);
|
|
114
|
+
}
|