taskode 0.4.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.
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function canonicalizePath(inputPath) {
5
+ if (typeof inputPath !== 'string' || inputPath.trim() === '') {
6
+ throw new Error(`Invalid path: ${inputPath}`);
7
+ }
8
+
9
+ const absolutePath = path.resolve(inputPath);
10
+ const { root, segments } = splitAbsolutePath(absolutePath);
11
+ return resolveSegments(root, [], segments);
12
+ }
13
+
14
+ export function assertPathInsideRoot(candidatePath, rootPath) {
15
+ const canonicalCandidate = canonicalizePath(candidatePath);
16
+ const canonicalRoot = canonicalizePath(rootPath);
17
+
18
+ if (canonicalCandidate === canonicalRoot) {
19
+ throw new Error(`Refusing to operate on workspace root: ${canonicalCandidate}`);
20
+ }
21
+
22
+ const rootPrefix = canonicalRoot.endsWith(path.sep) ? canonicalRoot : `${canonicalRoot}${path.sep}`;
23
+ const candidatePrefix = canonicalCandidate.endsWith(path.sep)
24
+ ? canonicalCandidate
25
+ : `${canonicalCandidate}${path.sep}`;
26
+
27
+ if (!candidatePrefix.startsWith(rootPrefix)) {
28
+ throw new Error(`Path escapes workspace root: ${canonicalCandidate}`);
29
+ }
30
+
31
+ return canonicalCandidate;
32
+ }
33
+
34
+ export function assertRemotePathSafe(remotePath) {
35
+ if (typeof remotePath !== 'string' || remotePath.trim() === '') {
36
+ throw new Error('Remote workspace path must be a non-empty string');
37
+ }
38
+ if (/[\0\r\n]/.test(remotePath)) {
39
+ throw new Error(`Invalid remote workspace path: ${remotePath}`);
40
+ }
41
+ return remotePath;
42
+ }
43
+
44
+ function splitAbsolutePath(absolutePath) {
45
+ const parsed = path.parse(absolutePath);
46
+ const root = parsed.root || path.sep;
47
+ const relative = absolutePath.slice(root.length);
48
+ const segments = relative ? relative.split(path.sep).filter(Boolean) : [];
49
+ return { root, segments };
50
+ }
51
+
52
+ function resolveSegments(root, resolvedSegments, pendingSegments) {
53
+ if (!pendingSegments.length) {
54
+ return joinPath(root, resolvedSegments);
55
+ }
56
+
57
+ const [segment, ...rest] = pendingSegments;
58
+ const candidatePath = joinPath(root, [...resolvedSegments, segment]);
59
+
60
+ try {
61
+ const stat = fs.lstatSync(candidatePath);
62
+ if (stat.isSymbolicLink()) {
63
+ const linkTarget = fs.readlinkSync(candidatePath);
64
+ const resolvedTarget = path.resolve(joinPath(root, resolvedSegments), linkTarget);
65
+ const splitTarget = splitAbsolutePath(resolvedTarget);
66
+ return resolveSegments(splitTarget.root, [], [...splitTarget.segments, ...rest]);
67
+ }
68
+ } catch (error) {
69
+ if (error?.code === 'ENOENT') {
70
+ return joinPath(root, [...resolvedSegments, segment, ...rest]);
71
+ }
72
+ throw new Error(`Unable to inspect path ${candidatePath}: ${error.message}`);
73
+ }
74
+
75
+ return resolveSegments(root, [...resolvedSegments, segment], rest);
76
+ }
77
+
78
+ function joinPath(root, segments) {
79
+ return segments.reduce((current, segment) => path.join(current, segment), root);
80
+ }
package/src/policy.js ADDED
@@ -0,0 +1,23 @@
1
+ export function assertCommandAllowed(command, policy) {
2
+ const deny = policy.denyCommands || [];
3
+ if (matchesAny(command, deny)) {
4
+ throw new Error(`Command denied by policy: ${command}`);
5
+ }
6
+
7
+ const allow = policy.allowCommands || ['*'];
8
+ if (!matchesAny(command, allow)) {
9
+ throw new Error(`Command not allowed by policy: ${command}`);
10
+ }
11
+ }
12
+
13
+ function matchesAny(text, patterns) {
14
+ if (!patterns.length) return false;
15
+ return patterns.some((pattern) => {
16
+ if (pattern === '*') return true;
17
+ if (pattern.startsWith('/') && pattern.endsWith('/')) {
18
+ const regex = new RegExp(pattern.slice(1, -1));
19
+ return regex.test(text);
20
+ }
21
+ return text.includes(pattern);
22
+ });
23
+ }
package/src/review.js ADDED
@@ -0,0 +1,197 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ const INTERNAL_IGNORES = new Set(['.git', '.taskode', 'node_modules']);
6
+
7
+ export function buildRunArtifacts({ projectRoot, workspacePath, logsRoot, runId, ignoreNames = [] }) {
8
+ if (!workspacePath || !fs.existsSync(workspacePath)) {
9
+ return emptyArtifacts();
10
+ }
11
+
12
+ const changes = collectWorkspaceChanges({ projectRoot, workspacePath, ignoreNames });
13
+ const diffText = generateWorkspaceDiff({ projectRoot, workspacePath, changes });
14
+ const diffFile = writeDiffFile({ logsRoot, runId, diffText });
15
+
16
+ return {
17
+ changedFiles: changes,
18
+ diffSummary: summarizeChanges(changes),
19
+ diffText,
20
+ diffFile
21
+ };
22
+ }
23
+
24
+ export function collectWorkspaceChanges({ projectRoot, workspacePath, ignoreNames = [] }) {
25
+ const ignoreSet = new Set([...INTERNAL_IGNORES, ...ignoreNames]);
26
+ const projectFiles = walkFiles(projectRoot, ignoreSet);
27
+ const workspaceFiles = walkFiles(workspacePath, ignoreSet);
28
+ const changes = [];
29
+
30
+ for (const relativePath of new Set([...projectFiles.keys(), ...workspaceFiles.keys()])) {
31
+ const projectEntry = projectFiles.get(relativePath);
32
+ const workspaceEntry = workspaceFiles.get(relativePath);
33
+
34
+ if (!projectEntry && workspaceEntry) {
35
+ changes.push({ path: relativePath, status: 'added' });
36
+ continue;
37
+ }
38
+
39
+ if (projectEntry && !workspaceEntry) {
40
+ changes.push({ path: relativePath, status: 'deleted' });
41
+ continue;
42
+ }
43
+
44
+ if (!entriesEqual(projectEntry, workspaceEntry)) {
45
+ changes.push({ path: relativePath, status: 'modified' });
46
+ }
47
+ }
48
+
49
+ return changes.sort((a, b) => a.path.localeCompare(b.path));
50
+ }
51
+
52
+ export function applyWorkspaceChanges({ projectRoot, workspacePath, changes, ignoreNames = [] }) {
53
+ const effectiveChanges = changes?.length
54
+ ? changes
55
+ : collectWorkspaceChanges({ projectRoot, workspacePath, ignoreNames });
56
+
57
+ for (const change of effectiveChanges) {
58
+ const projectFile = resolveSafeChild(projectRoot, change.path);
59
+ const workspaceFile = resolveSafeChild(workspacePath, change.path);
60
+
61
+ if (change.status === 'deleted') {
62
+ fs.rmSync(projectFile, { force: true });
63
+ removeEmptyParents(path.dirname(projectFile), projectRoot);
64
+ continue;
65
+ }
66
+
67
+ ensureDir(path.dirname(projectFile));
68
+ fs.copyFileSync(workspaceFile, projectFile);
69
+ }
70
+
71
+ return effectiveChanges;
72
+ }
73
+
74
+ function walkFiles(root, ignoreSet, basePath = '', files = new Map()) {
75
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
76
+ if (ignoreSet.has(entry.name)) continue;
77
+
78
+ const absolutePath = path.join(root, entry.name);
79
+ const relativePath = basePath ? path.join(basePath, entry.name) : entry.name;
80
+
81
+ if (entry.isDirectory()) {
82
+ walkFiles(absolutePath, ignoreSet, relativePath, files);
83
+ continue;
84
+ }
85
+
86
+ files.set(relativePath, snapshotEntry(absolutePath, entry));
87
+ }
88
+
89
+ return files;
90
+ }
91
+
92
+ function snapshotEntry(absolutePath, entry) {
93
+ if (entry.isSymbolicLink()) {
94
+ return {
95
+ type: 'symlink',
96
+ value: fs.readlinkSync(absolutePath)
97
+ };
98
+ }
99
+
100
+ return {
101
+ type: 'file',
102
+ value: fs.readFileSync(absolutePath)
103
+ };
104
+ }
105
+
106
+ function entriesEqual(left, right) {
107
+ if (!left || !right) return false;
108
+ if (left.type !== right.type) return false;
109
+ if (left.type === 'symlink') return left.value === right.value;
110
+ return Buffer.compare(left.value, right.value) === 0;
111
+ }
112
+
113
+ function generateWorkspaceDiff({ projectRoot, workspacePath, changes }) {
114
+ const sections = [];
115
+
116
+ for (const change of changes) {
117
+ const left = change.status === 'added' ? '/dev/null' : path.join(projectRoot, change.path);
118
+ const right = change.status === 'deleted' ? '/dev/null' : path.join(workspacePath, change.path);
119
+ const result = spawnSync('git', ['diff', '--no-index', '--no-ext-diff', '--text', '--', left, right], {
120
+ encoding: 'utf8',
121
+ maxBuffer: 10 * 1024 * 1024
122
+ });
123
+
124
+ if (result.error) {
125
+ return renderFallbackDiff(changes);
126
+ }
127
+
128
+ if (![0, 1].includes(result.status ?? 0)) {
129
+ throw new Error(result.stderr || `git diff failed for ${change.path}`);
130
+ }
131
+
132
+ if (result.stdout.trim()) {
133
+ sections.push(result.stdout.trimEnd());
134
+ }
135
+ }
136
+
137
+ return sections.join('\n\n');
138
+ }
139
+
140
+ function renderFallbackDiff(changes) {
141
+ return changes.map((change) => `${change.status.toUpperCase()} ${change.path}`).join('\n');
142
+ }
143
+
144
+ function writeDiffFile({ logsRoot, runId, diffText }) {
145
+ if (!diffText) return null;
146
+ ensureDir(logsRoot);
147
+ const diffFile = path.join(logsRoot, `${runId}.diff`);
148
+ fs.writeFileSync(diffFile, `${diffText}\n`, 'utf8');
149
+ return diffFile;
150
+ }
151
+
152
+ function summarizeChanges(changes) {
153
+ const summary = { total: changes.length, added: 0, modified: 0, deleted: 0 };
154
+ for (const change of changes) {
155
+ if (change.status === 'added') summary.added += 1;
156
+ if (change.status === 'modified') summary.modified += 1;
157
+ if (change.status === 'deleted') summary.deleted += 1;
158
+ }
159
+ return summary;
160
+ }
161
+
162
+ function removeEmptyParents(startDir, stopDir) {
163
+ let current = startDir;
164
+ while (current.startsWith(stopDir) && current !== stopDir) {
165
+ if (fs.existsSync(current) && fs.readdirSync(current).length === 0) {
166
+ fs.rmdirSync(current);
167
+ current = path.dirname(current);
168
+ continue;
169
+ }
170
+ break;
171
+ }
172
+ }
173
+
174
+ function ensureDir(dir) {
175
+ fs.mkdirSync(dir, { recursive: true });
176
+ }
177
+
178
+ function emptyArtifacts() {
179
+ return {
180
+ changedFiles: [],
181
+ diffSummary: { total: 0, added: 0, modified: 0, deleted: 0 },
182
+ diffText: '',
183
+ diffFile: null
184
+ };
185
+ }
186
+
187
+ function resolveSafeChild(root, relativePath) {
188
+ const normalized = path.normalize(String(relativePath || ''));
189
+ const absolute = path.resolve(root, normalized);
190
+ const rootPrefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
191
+
192
+ if (absolute !== root && !absolute.startsWith(rootPrefix)) {
193
+ throw new Error(`Refusing to access path outside root: ${relativePath}`);
194
+ }
195
+
196
+ return absolute;
197
+ }
package/src/runner.js ADDED
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { exec } from 'node:child_process';
4
+ import { Buffer } from 'node:buffer';
5
+ import { runAppServerSession } from './app-server.js';
6
+ import { assertCommandAllowed } from './policy.js';
7
+ import { runSSHCommand } from './ssh.js';
8
+
9
+ export class AgentRunner {
10
+ constructor({ config, store }) {
11
+ this.config = config;
12
+ this.store = store;
13
+ }
14
+
15
+ async run({ issue, workspacePath, prompt, runId, tracker, workerHost = null }) {
16
+ const command = this.buildCommand();
17
+ assertCommandAllowed(command, this.config.policy);
18
+
19
+ await writePromptArtifact(workspacePath, prompt, workerHost);
20
+
21
+ if (this.config.codex.mode === 'app-server') {
22
+ return runAppServerSession({
23
+ command,
24
+ cwd: workspacePath,
25
+ prompt,
26
+ issue,
27
+ tracker,
28
+ config: this.config,
29
+ maxTurns: this.config.codex.maxTurns,
30
+ runId,
31
+ store: this.store,
32
+ workerHost
33
+ });
34
+ }
35
+
36
+ return runShell({
37
+ command,
38
+ cwd: workspacePath,
39
+ timeoutMs: this.config.codex.timeoutMs,
40
+ issue,
41
+ workerHost
42
+ });
43
+ }
44
+
45
+ buildCommand() {
46
+ return this.config.codex.command;
47
+ }
48
+ }
49
+
50
+ async function writePromptArtifact(workspacePath, prompt, workerHost) {
51
+ if (!workerHost) {
52
+ const taskodeDir = path.join(workspacePath, '.taskode');
53
+ fs.mkdirSync(taskodeDir, { recursive: true });
54
+ fs.writeFileSync(path.join(taskodeDir, 'prompt.txt'), prompt, 'utf8');
55
+ return;
56
+ }
57
+
58
+ const encodedPrompt = Buffer.from(prompt, 'utf8').toString('base64');
59
+ await runSSHCommand(
60
+ workerHost,
61
+ `cd '${workspacePath.replaceAll("'", `'\"'\"'`)}' && mkdir -p .taskode && printf %s '${encodedPrompt}' | base64 -d > .taskode/prompt.txt`
62
+ );
63
+ }
64
+
65
+ function runShell({ command, cwd, timeoutMs, issue, workerHost }) {
66
+ const startedAt = Date.now();
67
+
68
+ if (workerHost) {
69
+ return runRemoteShell({ command, cwd, timeoutMs, issue, startedAt, workerHost });
70
+ }
71
+
72
+ return new Promise((resolve) => {
73
+ exec(command, { cwd, timeout: timeoutMs, shell: '/bin/bash' }, (error, stdout, stderr) => {
74
+ if (error) {
75
+ return resolve({
76
+ status: 'failed',
77
+ code: error.code ?? 1,
78
+ output: stdout || '',
79
+ error: stderr || error.message,
80
+ durationMs: Date.now() - startedAt,
81
+ telemetry: shellTelemetry(null)
82
+ });
83
+ }
84
+
85
+ return resolve({
86
+ status: 'succeeded',
87
+ code: 0,
88
+ output: stdout || `Processed issue ${issue.identifier}`,
89
+ error: stderr || '',
90
+ durationMs: Date.now() - startedAt,
91
+ telemetry: shellTelemetry(null)
92
+ });
93
+ });
94
+ });
95
+ }
96
+
97
+ async function runRemoteShell({ command, cwd, timeoutMs, issue, startedAt, workerHost }) {
98
+ try {
99
+ const { stdout, stderr } = await runSSHCommand(
100
+ workerHost,
101
+ `cd '${cwd.replaceAll("'", `'\"'\"'`)}' && ${command}`,
102
+ { timeoutMs }
103
+ );
104
+
105
+ return {
106
+ status: 'succeeded',
107
+ code: 0,
108
+ output: stdout || `Processed issue ${issue.identifier}`,
109
+ error: stderr || '',
110
+ durationMs: Date.now() - startedAt,
111
+ telemetry: shellTelemetry(workerHost)
112
+ };
113
+ } catch (error) {
114
+ return {
115
+ status: 'failed',
116
+ code: error.code ?? 1,
117
+ output: error.stdout || '',
118
+ error: error.stderr || error.message,
119
+ durationMs: Date.now() - startedAt,
120
+ telemetry: shellTelemetry(workerHost)
121
+ };
122
+ }
123
+ }
124
+
125
+ function shellTelemetry(workerHost) {
126
+ return {
127
+ turnCount: 1,
128
+ inputTokens: 0,
129
+ outputTokens: 0,
130
+ totalTokens: 0,
131
+ workerHost
132
+ };
133
+ }
package/src/server.js ADDED
@@ -0,0 +1,168 @@
1
+ import express from 'express';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { authMiddleware } from './auth.js';
6
+
7
+ export function createServer({ store, orchestrator, tracker, config }) {
8
+ const app = express();
9
+ app.use(express.json());
10
+
11
+ app.get('/api/health', (_req, res) => {
12
+ res.json({
13
+ ok: true,
14
+ now: new Date().toISOString(),
15
+ tracker: config.tracker.kind
16
+ });
17
+ });
18
+
19
+ app.use('/api', authMiddleware(config, store));
20
+
21
+ app.get('/api/tasks', async (_req, res) => {
22
+ try {
23
+ await syncRemoteTasksIfNeeded(tracker, config);
24
+ res.json({ tasks: store.listTasks() });
25
+ } catch (error) {
26
+ res.status(502).json({ error: error.message });
27
+ }
28
+ });
29
+
30
+ app.get('/api/tasks/:id', async (req, res) => {
31
+ try {
32
+ await syncRemoteTasksIfNeeded(tracker, config);
33
+ const task = store.getTask(req.params.id);
34
+ if (!task) return res.status(404).json({ error: 'task not found' });
35
+ return res.json({ task, runs: store.listRuns(task.id) });
36
+ } catch (error) {
37
+ return res.status(502).json({ error: error.message });
38
+ }
39
+ });
40
+
41
+ app.post('/api/tasks', (req, res) => {
42
+ if (config.tracker.kind !== 'local') {
43
+ return res.status(400).json({ error: 'task creation is only supported in local tracker mode' });
44
+ }
45
+
46
+ const { title, description, type, priority } = req.body || {};
47
+ if (!title || typeof title !== 'string') {
48
+ return res.status(400).json({ error: 'title is required' });
49
+ }
50
+
51
+ const task = store.createTask({
52
+ title,
53
+ description,
54
+ type,
55
+ priority,
56
+ dependencies: normalizeDependencies(req.body?.dependencies)
57
+ });
58
+
59
+ store.appendAudit({ action: 'task_created', actor: req.ip, metadata: { taskId: task.id } });
60
+ return res.status(201).json({ task });
61
+ });
62
+
63
+ app.patch('/api/tasks/:id', (req, res) => {
64
+ const task = store.updateTask(req.params.id, {
65
+ ...req.body,
66
+ dependencies: req.body?.dependencies !== undefined ? normalizeDependencies(req.body.dependencies) : undefined
67
+ });
68
+
69
+ if (!task) {
70
+ return res.status(404).json({ error: 'task not found' });
71
+ }
72
+
73
+ store.appendAudit({ action: 'task_updated', actor: req.ip, metadata: { taskId: task.id } });
74
+ return res.json({ task });
75
+ });
76
+
77
+ app.post('/api/tasks/:id/requeue', async (req, res) => {
78
+ try {
79
+ const task = await orchestrator.requeueTask(req.params.id);
80
+ return res.json({ task });
81
+ } catch (error) {
82
+ return res.status(400).json({ error: error.message });
83
+ }
84
+ });
85
+
86
+ app.get('/api/runs', (_req, res) => {
87
+ res.json({ runs: store.listRuns(_req.query.taskId) });
88
+ });
89
+
90
+ app.get('/api/runs/:id', (req, res) => {
91
+ const run = store.getRun(req.params.id);
92
+ if (!run) return res.status(404).json({ error: 'run not found' });
93
+ return res.json({ run });
94
+ });
95
+
96
+ app.get('/api/runs/:id/diff', (req, res) => {
97
+ const run = store.getRun(req.params.id);
98
+ if (!run) return res.status(404).json({ error: 'run not found' });
99
+ if (!run.diffFile || !fs.existsSync(run.diffFile)) return res.type('text/plain').send('');
100
+ return res.type('text/plain').send(fs.readFileSync(run.diffFile, 'utf8'));
101
+ });
102
+
103
+ app.post('/api/runs/:id/approve', async (req, res) => {
104
+ try {
105
+ const run = await orchestrator.approveRun(req.params.id);
106
+ return res.json({ run });
107
+ } catch (error) {
108
+ return res.status(400).json({ error: error.message });
109
+ }
110
+ });
111
+
112
+ app.post('/api/runs/:id/reject', async (req, res) => {
113
+ try {
114
+ const run = await orchestrator.rejectRun(req.params.id, req.body?.reason || '');
115
+ return res.json({ run });
116
+ } catch (error) {
117
+ return res.status(400).json({ error: error.message });
118
+ }
119
+ });
120
+
121
+ app.get('/api/logs', (_req, res) => {
122
+ res.json({ logs: store.listSystemLogs() });
123
+ });
124
+
125
+ app.get('/api/audit', (_req, res) => {
126
+ res.json({ audit: store.listAudit() });
127
+ });
128
+
129
+ app.get('/api/v1/state', (_req, res) => {
130
+ res.json({
131
+ workflow_path: config.workflowPath,
132
+ tracker: config.tracker.kind,
133
+ state: orchestrator.getState()
134
+ });
135
+ });
136
+
137
+ app.get('/api/v1/:issueIdentifier', (req, res) => {
138
+ const task = store.getTask(req.params.issueIdentifier);
139
+ const runs = store.listRuns(req.params.issueIdentifier);
140
+ return res.json({ task, runs });
141
+ });
142
+
143
+ app.post('/api/v1/refresh', async (req, res) => {
144
+ await orchestrator.tick();
145
+ store.appendAudit({ action: 'manual_refresh', actor: req.ip, metadata: {} });
146
+ res.json({ ok: true });
147
+ });
148
+
149
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
150
+ app.use('/', express.static(path.join(__dirname, '..', 'public')));
151
+
152
+ return app;
153
+ }
154
+
155
+ async function syncRemoteTasksIfNeeded(tracker, config) {
156
+ if (config.tracker.kind === 'local') return;
157
+ await tracker.fetchCandidates();
158
+ }
159
+
160
+ function normalizeDependencies(value) {
161
+ if (Array.isArray(value)) {
162
+ return [...new Set(value.map((entry) => String(entry || '').trim()).filter(Boolean))];
163
+ }
164
+ if (typeof value === 'string') {
165
+ return [...new Set(value.split(',').map((entry) => entry.trim()).filter(Boolean))];
166
+ }
167
+ return [];
168
+ }
package/src/ssh.js ADDED
@@ -0,0 +1,82 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+
3
+ export function runSSHCommand(host, command, options = {}) {
4
+ return new Promise((resolve, reject) => {
5
+ const executable = findSshExecutable();
6
+ const child = execFile(
7
+ executable,
8
+ buildSshArgs(host, command),
9
+ {
10
+ timeout: options.timeoutMs,
11
+ cwd: options.cwd,
12
+ encoding: 'utf8',
13
+ maxBuffer: options.maxBuffer ?? 10 * 1024 * 1024
14
+ },
15
+ (error, stdout, stderr) => {
16
+ if (error) {
17
+ error.stdout = stdout;
18
+ error.stderr = stderr;
19
+ reject(error);
20
+ return;
21
+ }
22
+ resolve({ stdout, stderr });
23
+ }
24
+ );
25
+
26
+ if (options.stdin) {
27
+ child.stdin?.end(options.stdin);
28
+ }
29
+ });
30
+ }
31
+
32
+ export function startSSHProcess(host, command, options = {}) {
33
+ const executable = findSshExecutable();
34
+ return spawn(executable, buildSshArgs(host, command), {
35
+ cwd: options.cwd,
36
+ stdio: options.stdio || ['pipe', 'pipe', 'pipe']
37
+ });
38
+ }
39
+
40
+ export function remoteShellCommand(command) {
41
+ return `bash -lc ${shellEscape(command)}`;
42
+ }
43
+
44
+ export function shellEscape(value) {
45
+ return `'${String(value).replaceAll("'", `'\"'\"'`)}'`;
46
+ }
47
+
48
+ function buildSshArgs(host, command) {
49
+ const args = [];
50
+ const configPath = process.env.TASKODE_SSH_CONFIG;
51
+ if (configPath) {
52
+ args.push('-F', configPath);
53
+ }
54
+
55
+ args.push('-T');
56
+
57
+ const target = parseTarget(host);
58
+ if (target.port) {
59
+ args.push('-p', target.port);
60
+ }
61
+
62
+ args.push(target.destination, remoteShellCommand(command));
63
+ return args;
64
+ }
65
+
66
+ function parseTarget(host) {
67
+ const trimmed = String(host || '').trim();
68
+ const match = trimmed.match(/^(.*):(\d+)$/);
69
+ if (match && isPortDestination(match[1])) {
70
+ return { destination: match[1], port: match[2] };
71
+ }
72
+ return { destination: trimmed, port: null };
73
+ }
74
+
75
+ function isPortDestination(destination) {
76
+ return destination !== '' && (!destination.includes(':') || (destination.includes('[') && destination.includes(']')));
77
+ }
78
+
79
+ function findSshExecutable() {
80
+ const executable = process.env.TASKODE_SSH_BIN || 'ssh';
81
+ return executable;
82
+ }