dsh-ros2-common 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 dsh-ros2 contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # dsh-ros2-common
2
+
3
+ Shared runtime for the dsh-ros2 plugin family — **not a cordis bundle**.
4
+
5
+ - `runner.ts` — command runner (timeout / SIGKILL / ROS_LOG_DIR fallback / rosSetup), `spawnJob`
6
+ - `parse.ts` — topic/graph/transform parsers
7
+ - `toolkit.ts` — ToolDeps injection interface, result helpers, approval gate, `/safety/state` gate, profile loading (`robot_profile.py` via `commonScriptPath`), vision provider contract, `ros2Tool` adapter, `makeRun`
8
+ - `scripts/robot_profile.py` — robot body profile + topology knowledge base (**zero-copy** shared by profile / moveit / safety)
9
+
10
+ Consumers depend on `dsh-ros2-common` and import from the package root.
11
+
package/lib/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * dsh-ros2-common — shared runtime for the dsh-ros2 plugin family.
3
+ * Plain library (NOT a cordis bundle): command runner, parsers, ToolDeps
4
+ * toolkit, and the robot-profile script (zero-copy across packages).
5
+ */
6
+ export * from './toolkit.js';
7
+ export * from './runner.js';
8
+ export * from './parse.js';
package/lib/parse.js ADDED
@@ -0,0 +1,110 @@
1
+ /** Pure parsers for `ros2 ...` CLI output. Kept side-effect free for testing. */
2
+ /** Split stdout into non-empty trimmed lines. */
3
+ export function parseLines(stdout) {
4
+ return stdout.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
5
+ }
6
+ /** Parse `ros2 topic/service/action list` output: `name [type]` or `name`. */
7
+ export function parseTopicList(stdout) {
8
+ return parseLines(stdout).map((line) => {
9
+ const match = line.match(/^(\S+)(?:\s*\[\s*([^\]]+)\s*\])?$/);
10
+ if (!match)
11
+ return { name: line };
12
+ return match[2] ? { name: match[1], type: match[2].trim() } : { name: match[1] };
13
+ });
14
+ }
15
+ const NODE_INFO_SECTIONS = [
16
+ ['subscribers', 'Subscribers'],
17
+ ['publishers', 'Publishers'],
18
+ ['serviceServers', 'Service Servers'],
19
+ ['serviceClients', 'Service Clients'],
20
+ ['actionServers', 'Action Servers'],
21
+ ['actionClients', 'Action Clients'],
22
+ ];
23
+ /** Parse `ros2 node info <node>` (Jazzy layout). */
24
+ export function parseNodeInfo(stdout, fallbackNode = '') {
25
+ const info = {
26
+ node: fallbackNode,
27
+ subscribers: [],
28
+ publishers: [],
29
+ serviceServers: [],
30
+ serviceClients: [],
31
+ actionServers: [],
32
+ actionClients: [],
33
+ };
34
+ let section = null;
35
+ for (const raw of stdout.split('\n')) {
36
+ const line = raw.trim();
37
+ if (line.length === 0)
38
+ continue;
39
+ const header = NODE_INFO_SECTIONS.find(([, label]) => line === label || line === `${label}:`);
40
+ if (header) {
41
+ section = header[0];
42
+ continue;
43
+ }
44
+ if (section === null) {
45
+ info.node = line;
46
+ continue;
47
+ }
48
+ const entry = line.replace(/^[:.\-]\s*/, '');
49
+ if (section === 'subscribers' || section === 'publishers') {
50
+ const match = entry.match(/^(\S+)(?:\s*:\s*(.+))?$/);
51
+ info[section].push(match ? { name: match[1], ...(match[2] ? { type: match[2].trim() } : {}) } : { name: entry });
52
+ }
53
+ else {
54
+ ;
55
+ info[section].push(entry);
56
+ }
57
+ }
58
+ return info;
59
+ }
60
+ /** Fold per-node info into a graph: node roster plus unique topic names. */
61
+ export function foldGraph(nodes) {
62
+ const topics = new Set();
63
+ const folded = nodes.map((n) => {
64
+ const publishers = n.publishers.map((p) => p.name);
65
+ const subscribers = n.subscribers.map((s) => s.name);
66
+ for (const t of [...publishers, ...subscribers])
67
+ topics.add(t);
68
+ return {
69
+ name: n.node,
70
+ publishers,
71
+ subscribers,
72
+ services: [...n.serviceServers, ...n.serviceClients],
73
+ actions: [...n.actionServers, ...n.actionClients],
74
+ };
75
+ });
76
+ return { nodes: folded, topics: [...topics].sort(), nodeCount: folded.length };
77
+ }
78
+ /** Best-effort JSON parse of a topic sample; falls back to raw text. */
79
+ export function parseJsonOrRaw(stdout) {
80
+ const text = stdout.trim();
81
+ if (text.length === 0)
82
+ return null;
83
+ try {
84
+ return JSON.parse(text);
85
+ }
86
+ catch {
87
+ return { raw: text.slice(0, 4000) };
88
+ }
89
+ }
90
+ export function parseTransforms(value) {
91
+ if (!Array.isArray(value))
92
+ return [];
93
+ const seen = new Set();
94
+ const pairs = [];
95
+ for (const entry of value) {
96
+ if (typeof entry !== 'object' || entry === null)
97
+ continue;
98
+ const header = entry.header;
99
+ const child = entry.child_frame_id;
100
+ const parent = header?.frame_id;
101
+ if (typeof parent === 'string' && typeof child === 'string') {
102
+ const key = `${parent}::${child}`;
103
+ if (!seen.has(key)) {
104
+ seen.add(key);
105
+ pairs.push({ parent, child });
106
+ }
107
+ }
108
+ }
109
+ return pairs;
110
+ }
package/lib/runner.js ADDED
@@ -0,0 +1,168 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ const DEFAULT_TIMEOUT_MS = 15000;
3
+ const MAX_BUFFER = 16 * 1024 * 1024;
4
+ function execFileP(bin, args, options) {
5
+ return new Promise((resolve, reject) => {
6
+ execFile(bin, args, options, (error, stdout, stderr) => {
7
+ const toText = (value) => (typeof value === 'string' ? value : value.toString('utf8'));
8
+ if (error) {
9
+ const e = error;
10
+ e.stdout = toText(stdout);
11
+ e.stderr = toText(stderr);
12
+ reject(error);
13
+ return;
14
+ }
15
+ resolve({ stdout: toText(stdout), stderr: toText(stderr) });
16
+ });
17
+ });
18
+ }
19
+ /** POSIX single-quote escape for embedding one argument into a shell string. */
20
+ function shq(value) {
21
+ return `'${value.replace(/'/g, `'\\''`)}'`;
22
+ }
23
+ /**
24
+ * Run a CLI command (default binary `ros2`) and normalize the outcome.
25
+ * Non-zero exits and timeouts are reported as `ok: false` — the caller decides
26
+ * whether they are findings or failures.
27
+ */
28
+ export async function runCommand(bin, args, opts = {}) {
29
+ const startedAt = Date.now();
30
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
31
+ const cwd = opts.cwd;
32
+ const command = `${bin} ${args.map(shq).join(' ')}`;
33
+ const env = {
34
+ ...process.env,
35
+ ...(opts.rosLogDir ? { ROS_LOG_DIR: opts.rosLogDir } : {}),
36
+ ...opts.env,
37
+ };
38
+ ensureWritableRosLogDir(env);
39
+ const baseOptions = {
40
+ timeout: timeoutMs,
41
+ killSignal: 'SIGKILL',
42
+ maxBuffer: MAX_BUFFER,
43
+ cwd,
44
+ env,
45
+ };
46
+ try {
47
+ const { stdout, stderr } = opts.rosSetup
48
+ ? await execFileP('bash', ['-lc', `${opts.rosSetup} ${command}`], baseOptions)
49
+ : await execFileP(bin, args, baseOptions);
50
+ return { ok: true, command, stdout, stderr, exitCode: 0, timedOut: false, durationMs: Date.now() - startedAt };
51
+ }
52
+ catch (error) {
53
+ const e = error;
54
+ const timedOut = e.killed === true || e.signal === 'SIGKILL';
55
+ const exitCode = typeof e.code === 'number' ? e.code : null;
56
+ return {
57
+ ok: false,
58
+ command,
59
+ stdout: e.stdout ?? '',
60
+ stderr: e.stderr ?? '',
61
+ exitCode,
62
+ timedOut,
63
+ durationMs: Date.now() - startedAt,
64
+ error: timedOut ? `timed out after ${timeoutMs}ms` : e.message,
65
+ };
66
+ }
67
+ }
68
+ /** Convenience for `ros2 ...` subcommands. */
69
+ export function runRos2(args, opts = {}) {
70
+ return runCommand('ros2', args, opts);
71
+ }
72
+ // ── writable ROS log dir fallback ──────────────────────────────────────
73
+ // ROS2 Python CLIs (topic echo/pub, ros2 run) abort at startup when
74
+ // ~/.ros/log is not writable. When no explicit ROS_LOG_DIR is configured,
75
+ // probe once and transparently fall back to a writable per-user dir so the
76
+ // plugin works on locked-down/headless hosts out of the box.
77
+ let rosLogProbed = false;
78
+ let rosLogFallback;
79
+ function probeRosLogFallback() {
80
+ if (rosLogProbed)
81
+ return rosLogFallback;
82
+ rosLogProbed = true;
83
+ const home = process.env.HOME;
84
+ if (home) {
85
+ const target = `${home}/.ros/log`;
86
+ try {
87
+ const { mkdirSync, writeFileSync, rmSync } = require('node:fs');
88
+ mkdirSync(target, { recursive: true });
89
+ const probe = `${target}/.dsh-writable-probe`;
90
+ writeFileSync(probe, 'ok');
91
+ rmSync(probe);
92
+ return undefined; // writable — no fallback needed
93
+ }
94
+ catch {
95
+ // fall through to /tmp
96
+ }
97
+ }
98
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
99
+ rosLogFallback = `/tmp/ros-log-${uid}`;
100
+ try {
101
+ require('node:fs').mkdirSync(rosLogFallback, { recursive: true });
102
+ }
103
+ catch {
104
+ rosLogFallback = undefined;
105
+ }
106
+ return rosLogFallback;
107
+ }
108
+ /** Set ROS_LOG_DIR in `env` to a writable dir when the default would fail. */
109
+ export function ensureWritableRosLogDir(env) {
110
+ if (env.ROS_LOG_DIR)
111
+ return;
112
+ if (process.env.ROS_LOG_DIR) {
113
+ env.ROS_LOG_DIR = process.env.ROS_LOG_DIR;
114
+ return;
115
+ }
116
+ const fallback = probeRosLogFallback();
117
+ if (fallback)
118
+ env.ROS_LOG_DIR = fallback;
119
+ }
120
+ /** Keep the last N lines of a stream, bounded. */
121
+ export function tailLines(text, n = 8) {
122
+ const lines = text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
123
+ return lines.slice(-n);
124
+ }
125
+ /**
126
+ * Spawn a long-running CLI process for a background job: bounded captured
127
+ * output, cancel via SIGTERM, settled outcome on close/error.
128
+ */
129
+ export function spawnJob(bin, args, opts = {}) {
130
+ const child = spawn(bin, args, {
131
+ cwd: opts.cwd,
132
+ env: opts.env ?? process.env,
133
+ stdio: ['ignore', 'pipe', 'pipe'],
134
+ });
135
+ const limit = opts.outputLimitBytes ?? 8 * 1024 * 1024;
136
+ let out = '';
137
+ let err = '';
138
+ let truncated = false;
139
+ const append = (chunk, target) => {
140
+ if (truncated)
141
+ return target;
142
+ const text = chunk.toString();
143
+ if (target.length + text.length > limit) {
144
+ truncated = true;
145
+ return `${target}${text.slice(0, Math.max(0, limit - target.length))}\n[output truncated]`;
146
+ }
147
+ return target + text;
148
+ };
149
+ child.stdout?.on('data', (chunk) => { out = append(chunk, out); });
150
+ child.stderr?.on('data', (chunk) => { err = append(chunk, err); });
151
+ const done = new Promise((resolve) => {
152
+ child.on('error', (error) => resolve({ status: 'failed', detail: error.message }));
153
+ child.on('close', (code, signal) => {
154
+ if (signal)
155
+ resolve({ status: 'killed', detail: `signal: ${signal}` });
156
+ else
157
+ resolve({ status: code === 0 ? 'completed' : 'failed', detail: `exit code: ${code ?? 'unknown'}` });
158
+ });
159
+ });
160
+ return {
161
+ cancel: () => {
162
+ if (!child.killed)
163
+ child.kill('SIGTERM');
164
+ },
165
+ readOutput: () => `[stdout]\n${out}\n[stderr]\n${err}`.trim(),
166
+ done,
167
+ };
168
+ }
package/lib/toolkit.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * dsh-ros2-common toolkit: types + helpers shared across the dsh-ros2 plugin
3
+ * family (core / profile / moveit / safety / vision). Not a cordis bundle —
4
+ * a plain library every domain package depends on.
5
+ *
6
+ * Shared seams: the ToolDeps injection interface, result helpers, approval
7
+ * gate, safety-state gate, profile loading, the vision provider contract,
8
+ * and the ros2Tool adapter used by read-only CLI tools.
9
+ */
10
+ import { fileURLToPath } from 'node:url';
11
+ import path from 'node:path';
12
+ import { defineTool } from '@deepseek-ai/dsh-tools';
13
+ import { runCommand } from './runner.js';
14
+ import { parseJsonOrRaw } from './parse.js';
15
+ export const resultSchema = {
16
+ type: 'object',
17
+ additionalProperties: false,
18
+ properties: {
19
+ ok: { type: 'boolean', required: true },
20
+ tool: { type: 'string', required: true },
21
+ command: { type: 'string', required: true },
22
+ data: { type: 'json', required: true },
23
+ warnings: { type: 'array', items: { type: 'string' } },
24
+ error: {
25
+ type: 'object',
26
+ additionalProperties: false,
27
+ properties: { code: { type: 'string' }, message: { type: 'string' } },
28
+ },
29
+ },
30
+ };
31
+ export const renderResult = (_args, value) => [{ type: 'text', text: JSON.stringify(value) }];
32
+ export { parseJsonOrRaw };
33
+ export { runCommand, spawnJob } from './runner.js';
34
+ export { foldGraph, parseLines, parseNodeInfo, parseTopicList, parseTransforms } from './parse.js';
35
+ /** Build the injected run seam from a package's config (mirrors legacy index.ts). */
36
+ export function makeRun(config) {
37
+ return (bin, args, opts = {}) => runCommand(bin, args, {
38
+ timeoutMs: opts.timeoutMs ?? config.timeoutMs,
39
+ rosLogDir: opts.rosLogDir ?? config.rosLogDir,
40
+ cwd: opts.cwd ?? (config.workspaceRoot.length > 0 ? config.workspaceRoot : undefined),
41
+ rosSetup: opts.rosSetup ?? config.rosSetup,
42
+ env: opts.env,
43
+ });
44
+ }
45
+ /** Path to a script shipped with dsh-ros2-common (e.g. robot_profile.py). */
46
+ export function commonScriptPath(name) {
47
+ return fileURLToPath(new URL(`../scripts/${name}`, import.meta.url));
48
+ }
49
+ /** Optional value helpers (legacy ToolDeps params are loose). */
50
+ export function strOrUndefined(value) {
51
+ return typeof value === 'string' ? value : undefined;
52
+ }
53
+ export function numOrUndefined(value) {
54
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
55
+ }
56
+ export function jsonOf(value) {
57
+ return value;
58
+ }
59
+ export function tail(stderr) {
60
+ return stderr.split('\n').map((l) => l.trim()).filter((l) => l.length > 0).slice(-8);
61
+ }
62
+ /** Only approval outcome that grants execution. */
63
+ const ALLOWED_ONCE = 'allowed-once';
64
+ /**
65
+ * Gate a write operation behind DSH user approval. Fails closed: no approval
66
+ * service, no owning agent, an error, or any non-grant outcome all deny.
67
+ */
68
+ export async function requestApproval(deps, exec, toolName, reason) {
69
+ if (!deps.approval || !exec.agent)
70
+ return { allowed: false, outcome: 'unavailable' };
71
+ try {
72
+ const outcome = await deps.approval({ agent: exec.agent, toolName, reason, signal: exec.signal });
73
+ return { allowed: outcome === ALLOWED_ONCE, outcome };
74
+ }
75
+ catch {
76
+ return { allowed: false, outcome: 'error' };
77
+ }
78
+ }
79
+ export function deniedResult(tool, command, outcome) {
80
+ return { ok: false, tool, command, data: null, error: { code: 'APPROVAL_DENIED', message: `approval ${outcome}` } };
81
+ }
82
+ /** Safety-gate rejection with a distinct error code (SAFETY_LOCKED / SAFETY_MONITOR_DOWN). */
83
+ export function safetyDenied(tool, command, code, message) {
84
+ return { ok: false, tool, command, data: null, error: { code, message } };
85
+ }
86
+ export function toolError(tool, command, code, message) {
87
+ return { ok: false, tool, command, data: null, error: { code, message } };
88
+ }
89
+ export function okResult(tool, command, data) {
90
+ return { ok: true, tool, command, data };
91
+ }
92
+ // ── safety framework helpers ─────────────────────────────────────────────
93
+ // Contract: docs/safety-handover.md — /safety/state is published by the
94
+ // safety_monitor node (SafetyState.msg, transient-local). Motion tools gate
95
+ // on it before executing.
96
+ const SAFETY_STATE_TOPIC = '/safety/state';
97
+ /** Parse the flat `field: value` echo output of SafetyState.msg. */
98
+ export function parseSafetyEcho(stdout) {
99
+ const out = {};
100
+ for (const line of stdout.split('\n')) {
101
+ const m = /^(\w+):\s*(.*)$/.exec(line.trim());
102
+ if (m && (m[1] === 'state' || m[1] === 'severity' || m[1] === 'cause' || m[1] === 'detail')) {
103
+ out[m[1]] = m[2];
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * Tool-layer safety gate for motion tools. A LOCKED /safety/state always
110
+ * rejects (with the trigger cause); an unreachable monitor rejects in
111
+ * 'reject' mode (fail-closed) or warns in 'warn' mode (backward compatible).
112
+ */
113
+ export async function enforceSafetyLock(deps, tool, command, opts = {}) {
114
+ if (opts.skip)
115
+ return {};
116
+ const res = await deps.run('ros2', ['topic', 'echo', SAFETY_STATE_TOPIC, '--once'], { timeoutMs: 3000 });
117
+ if (!res.ok || !res.stdout.trim()) {
118
+ const strict = deps.safetyStrict ?? 'warn';
119
+ if (strict === 'reject') {
120
+ return {
121
+ denied: safetyDenied(tool, command, 'SAFETY_MONITOR_DOWN', `safety_monitor 未运行(${SAFETY_STATE_TOPIC} 无响应),fail-closed 拒绝执行。请先 robot_safety_start 启动监视器,或配置 safetyStrict: 'warn' 放行。`),
122
+ };
123
+ }
124
+ return { warning: `safety_monitor 未运行(${SAFETY_STATE_TOPIC} 无响应)——已按 warn 模式放行;生产环境建议 safetyStrict: 'reject'。` };
125
+ }
126
+ const fields = parseSafetyEcho(res.stdout);
127
+ if (fields.state === 'LOCKED') {
128
+ return {
129
+ denied: safetyDenied(tool, command, 'SAFETY_LOCKED', `机器人已锁死(cause=${fields.cause ?? 'unknown'},severity=${fields.severity ?? '?'}):${fields.detail ?? ''}。需人工确认后经 robot_safety_unlock 解锁。`),
130
+ };
131
+ }
132
+ return {};
133
+ }
134
+ /** Read the current latched /safety/state (monitor may be offline). */
135
+ export async function readSafetyState(deps) {
136
+ const res = await deps.run('ros2', ['topic', 'echo', SAFETY_STATE_TOPIC, '--once'], { timeoutMs: 3000 });
137
+ if (!res.ok || !res.stdout.trim())
138
+ return { running: false, fields: {} };
139
+ return { running: true, fields: parseSafetyEcho(res.stdout) };
140
+ }
141
+ /** Locate a robot profile path (explicit path, or via robot_profile load). */
142
+ export async function resolveProfilePath(deps, robot, profile) {
143
+ if (profile)
144
+ return profile;
145
+ const res = await deps.run('python3', [commonScriptPath('robot_profile.py'), 'load', '--name', robot], { timeoutMs: 30000 });
146
+ if (res.ok && res.stdout.trim()) {
147
+ const data = parseJsonOrRaw(res.stdout);
148
+ if (data?.ok && data.profile_path)
149
+ return data.profile_path;
150
+ }
151
+ return '';
152
+ }
153
+ /** Load a registered robot profile (structured JSON, fast path). */
154
+ export async function loadRobotProfile(deps, robot) {
155
+ const res = await deps.run('python3', [commonScriptPath('robot_profile.py'), 'load', '--name', robot], { timeoutMs: 30000 });
156
+ if (!res.ok || !res.stdout.trim())
157
+ return null;
158
+ const data = parseJsonOrRaw(res.stdout);
159
+ if (!data?.ok || !data.robot)
160
+ return null;
161
+ return { robot: data.robot, ...(data.profile_path ? { profile_path: data.profile_path } : {}) };
162
+ }
163
+ /**
164
+ * Build an L1 read-only tool from a `ros2`-style command spec. Shared by
165
+ * dsh-ros2-core (diagnostics) and dsh-ros2-vision (image/VLM topics).
166
+ */
167
+ export function ros2Tool(deps, spec) {
168
+ const bin = spec.bin ?? 'ros2';
169
+ return defineTool({
170
+ name: spec.name,
171
+ description: spec.description,
172
+ parameters: (spec.parameters ?? {}),
173
+ output: {
174
+ schema: resultSchema,
175
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
176
+ },
177
+ async execute(args) {
178
+ const params = args;
179
+ const commandArgs = spec.buildArgs(params);
180
+ const res = await deps.run(bin, commandArgs, spec.runOpts?.(params) ?? {});
181
+ const command = `${bin} ${commandArgs.join(' ')}`;
182
+ if (!res.ok) {
183
+ if (spec.onNonZero && !res.timedOut) {
184
+ const value = {
185
+ ok: true,
186
+ tool: spec.name,
187
+ command,
188
+ data: spec.onNonZero(res),
189
+ ...(deps.includeStderr && res.stderr.trim() ? { warnings: tail(res.stderr) } : {}),
190
+ };
191
+ return value;
192
+ }
193
+ const value = {
194
+ ok: false,
195
+ tool: spec.name,
196
+ command,
197
+ data: null,
198
+ error: {
199
+ code: res.timedOut ? 'TIMEOUT' : 'COMMAND_FAILED',
200
+ message: res.error ?? `exit code ${res.exitCode ?? 'unknown'}`,
201
+ },
202
+ ...(res.stderr.trim() ? { warnings: tail(res.stderr) } : {}),
203
+ };
204
+ return value;
205
+ }
206
+ const data = spec.parse(res, params);
207
+ const warnings = deps.includeStderr && res.stderr.trim() ? tail(res.stderr) : undefined;
208
+ const value = { ok: true, tool: spec.name, command, data, ...(warnings ? { warnings } : {}) };
209
+ return value;
210
+ },
211
+ });
212
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * dsh-ros2-common — shared runtime for the dsh-ros2 plugin family.
3
+ * Plain library (NOT a cordis bundle): command runner, parsers, ToolDeps
4
+ * toolkit, and the robot-profile script (zero-copy across packages).
5
+ */
6
+ export * from './toolkit.js';
7
+ export * from './runner.js';
8
+ export * from './parse.js';
@@ -0,0 +1,45 @@
1
+ /** Pure parsers for `ros2 ...` CLI output. Kept side-effect free for testing. */
2
+ /** Lossless JSON value (same shape as DSH's JsonValue). */
3
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
4
+ [key: string]: JsonValue;
5
+ };
6
+ export type TopicEntry = {
7
+ name: string;
8
+ type?: string;
9
+ };
10
+ /** Split stdout into non-empty trimmed lines. */
11
+ export declare function parseLines(stdout: string): string[];
12
+ /** Parse `ros2 topic/service/action list` output: `name [type]` or `name`. */
13
+ export declare function parseTopicList(stdout: string): TopicEntry[];
14
+ export type NodeInfo = {
15
+ node: string;
16
+ subscribers: TopicEntry[];
17
+ publishers: TopicEntry[];
18
+ serviceServers: string[];
19
+ serviceClients: string[];
20
+ actionServers: string[];
21
+ actionClients: string[];
22
+ };
23
+ /** Parse `ros2 node info <node>` (Jazzy layout). */
24
+ export declare function parseNodeInfo(stdout: string, fallbackNode?: string): NodeInfo;
25
+ export type GraphNode = {
26
+ name: string;
27
+ publishers: string[];
28
+ subscribers: string[];
29
+ services: string[];
30
+ actions: string[];
31
+ };
32
+ /** Fold per-node info into a graph: node roster plus unique topic names. */
33
+ export declare function foldGraph(nodes: NodeInfo[]): {
34
+ nodes: GraphNode[];
35
+ topics: string[];
36
+ nodeCount: number;
37
+ };
38
+ /** Best-effort JSON parse of a topic sample; falls back to raw text. */
39
+ export declare function parseJsonOrRaw(stdout: string): JsonValue;
40
+ /** Unique frame pairs from a `/tf` transforms sample. */
41
+ export type FramePair = {
42
+ parent: string;
43
+ child: string;
44
+ };
45
+ export declare function parseTransforms(value: unknown): FramePair[];
@@ -0,0 +1,53 @@
1
+ export interface RosResult {
2
+ ok: boolean;
3
+ /** Display form of the command that ran. */
4
+ command: string;
5
+ stdout: string;
6
+ stderr: string;
7
+ /** Exit code when the process terminated normally, otherwise null. */
8
+ exitCode: number | null;
9
+ timedOut: boolean;
10
+ durationMs: number;
11
+ error?: string;
12
+ }
13
+ export interface RunOptions {
14
+ timeoutMs?: number;
15
+ cwd?: string;
16
+ rosLogDir?: string;
17
+ rosSetup?: string;
18
+ env?: Record<string, string>;
19
+ }
20
+ /**
21
+ * Run a CLI command (default binary `ros2`) and normalize the outcome.
22
+ * Non-zero exits and timeouts are reported as `ok: false` — the caller decides
23
+ * whether they are findings or failures.
24
+ */
25
+ export declare function runCommand(bin: string, args: string[], opts?: RunOptions): Promise<RosResult>;
26
+ /** Convenience for `ros2 ...` subcommands. */
27
+ export declare function runRos2(args: string[], opts?: RunOptions): Promise<RosResult>;
28
+ /** Set ROS_LOG_DIR in `env` to a writable dir when the default would fail. */
29
+ export declare function ensureWritableRosLogDir(env: Record<string, string>): void;
30
+ /** Keep the last N lines of a stream, bounded. */
31
+ export declare function tailLines(text: string, n?: number): string[];
32
+ /** Background job outcome vocabulary (matches the DSH jobs registry). */
33
+ export type JobOutcomeStatus = 'completed' | 'failed' | 'killed';
34
+ export interface JobOutcome {
35
+ status: JobOutcomeStatus;
36
+ detail?: string;
37
+ }
38
+ /** Producer hooks a `ctx.jobs.start` spec must return. */
39
+ export interface JobHooks {
40
+ cancel(): void;
41
+ readOutput?(): string;
42
+ done: Promise<JobOutcome>;
43
+ }
44
+ export interface SpawnJobOptions {
45
+ cwd?: string;
46
+ env?: Record<string, string>;
47
+ outputLimitBytes?: number;
48
+ }
49
+ /**
50
+ * Spawn a long-running CLI process for a background job: bounded captured
51
+ * output, cancel via SIGTERM, settled outcome on close/error.
52
+ */
53
+ export declare function spawnJob(bin: string, args: string[], opts?: SpawnJobOptions): JobHooks;