diff --git a/README.md b/README.md index 353b1a1..d275680 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ for what works, what's in progress, and what's still planned. | βœ… | **Encrypted passwords** | Stored passwords are encrypted with the OS keystore (DPAPI, Keychain, libsecret). | | βœ… | **1Password** | Read the address, port, user, domain or password of a host or a remote desktop connection from 1Password when connecting instead of storing it, and use SSH keys from the 1Password SSH agent. [How](#using-1password) | | βœ… | **Light & dark theme** | | -| 🚧 | **Snippets & automations** *(in progress)* | Save commands as snippets and chain them into automations on a canvas, run locally or on a host, with parameters and the output of earlier steps. Usable, but still changing. | +| 🚧 | **Snippets & automations** *(in progress)* | Save commands as snippets and chain them into automations on a canvas, run locally or on a host, with parameters and the output of earlier steps, plus an upload step that copies a file from this computer to the host. Usable, but still changing. | | 🚧 | **Remote desktop (RDP)** *(in progress)* | RDP sessions as tabs inside the app, next to your terminals (IronRDP, no extra window), or in the OS's own client (Remote Desktop on Windows, FreeRDP on Linux and macOS), signed in automatically either way. Drag files onto the session to copy them there, and save files copied on the remote desktop. Can tunnel through any SSH host, so machines behind a bastion work without exposing port 3389. Display, monitor, clipboard, drive and sound settings per profile. | | πŸ’‘ | **AI integration** *(maybe in the future)* | Help with commands, explain output or errors, right in the terminal. | | πŸ’‘ | **Plugin system** *(maybe in the future)* | Extend the app with your own features without touching the core. | diff --git a/packages/electron/src/core/automation/bundle.test.ts b/packages/electron/src/core/automation/bundle.test.ts index 401d8bf..7b1e9ee 100644 --- a/packages/electron/src/core/automation/bundle.test.ts +++ b/packages/electron/src/core/automation/bundle.test.ts @@ -51,6 +51,22 @@ describe('buildAutomationBundle', () => { }); }); +describe('upload nodes in bundles', () => { + it('bundle no snippet for an upload node, and come back through JSON', () => { + const a = snippet({ id: 'a1', name: 'Load' }); + const f = automation({ + params: [{ name: 'host', kind: 'host' }], + nodes: [ + node({ id: 'u', snippetId: '', upload: { from: 'image.tar.gz', to: '/tmp/' }, target: 'remote' }), + node({ id: 'n1', snippetId: 'a1', target: 'remote' }) + ] + }); + const bundle = buildAutomationBundle(f, new Map([['a1', a]])); + expect(bundle.snippets).toEqual([a]); + expect(parseBundle(JSON.parse(JSON.stringify(bundle)))).toEqual(bundle); + }); +}); + describe('parseBundle', () => { it('round-trips a built snippet bundle through JSON', () => { const a = snippet({ id: 'a1', name: 'Build' }); diff --git a/packages/electron/src/core/automation/bundle.ts b/packages/electron/src/core/automation/bundle.ts index 20cfe5c..adcec9a 100644 --- a/packages/electron/src/core/automation/bundle.ts +++ b/packages/electron/src/core/automation/bundle.ts @@ -39,7 +39,7 @@ export function buildAutomationBundle(automation: Automation, snippetsById: Map< const seen = new Set(); const snippets: Snippet[] = []; for (const node of automation.nodes) { - if (seen.has(node.snippetId)) continue; + if (node.upload !== undefined || seen.has(node.snippetId)) continue; const snippet = snippetsById.get(node.snippetId); if (snippet === undefined) { throw new Error(`automation "${automation.name}" references an unknown snippet`); @@ -105,9 +105,15 @@ function parseAutomationNode(raw: unknown, ctx: string): AutomationNode { const p = obj(o.position, `${ctx}.position`); position = { x: num(p.x, `${ctx}.position.x`), y: num(p.y, `${ctx}.position.y`) }; } + let upload: AutomationNode['upload']; + if (o.upload !== undefined && o.upload !== null) { + const u = obj(o.upload, `${ctx}.upload`); + upload = { from: str(u.from, `${ctx}.upload.from`), to: str(u.to, `${ctx}.upload.to`) }; + } return { id: str(o.id, `${ctx}.id`), - snippetId: str(o.snippetId, `${ctx}.snippetId`), + snippetId: upload !== undefined && o.snippetId === undefined ? '' : str(o.snippetId, `${ctx}.snippetId`), + upload, label: str(o.label, `${ctx}.label`), continueOnError: bool(o.continueOnError, `${ctx}.continueOnError`), target, diff --git a/packages/electron/src/core/automation/engine.integration.test.ts b/packages/electron/src/core/automation/engine.integration.test.ts index ecb71ac..a89036d 100644 --- a/packages/electron/src/core/automation/engine.integration.test.ts +++ b/packages/electron/src/core/automation/engine.integration.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import { runAutomation, type RunAutomationDeps } from './engine.js'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { runLocalCommand } from './localExec.js'; +import { uploadOverSession } from './upload.js'; import type { Snippet, Automation } from './types.js'; import { SshSession } from '../ssh/session.js'; import { testTargetHost } from '../../testSupport/sshTestTarget.js'; @@ -14,16 +18,55 @@ import { testTargetHost } from '../../testSupport/sshTestTarget.js'; function deps(): RunAutomationDeps { return { runLocal: runLocalCommand, - connectHost: async () => { + connectHost: async (hostName) => { const session = await SshSession.connect(testTargetHost()); return { runShell: (cmd, timeoutMs) => session.runShell(cmd, timeoutMs), + upload: (from, to) => uploadOverSession(session, hostName, from, to), disconnect: () => session.disconnect() }; } }; } +describe('upload node against the test target', () => { + it('copies a local file to the host, and the next node reads it where it landed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bssh-upload-')); + const file = join(dir, `payload-${Date.now()}.txt`); + await writeFile(file, 'hello from the upload node\n'); + try { + const read: Snippet = { id: 'read', name: 'Read', command: 'cat {{nodes.upload.output}} && rm {{nodes.upload.output}}', timeoutSecs: 30 }; + const automation: Automation = { + name: 'it-upload', + params: [{ name: 'host', kind: 'host' }], + nodes: [ + { id: 'u', snippetId: '', upload: { from: file, to: '/tmp/' }, label: 'upload', continueOnError: false, target: 'remote' }, + { id: 'r', snippetId: 'read', label: 'read', continueOnError: false, target: 'remote' } + ], + edges: [{ from: 'u', to: 'r' }] + }; + const results = await runAutomation(automation, new Map([['read', read]]), { host: 'ssh-test-target' }, deps()); + expect(results.map((r) => r.status)).toEqual(['success', 'success']); + expect(results[0].output).toMatch(/^\/tmp\/payload-\d+\.txt$/); + expect(results[1].output).toContain('hello from the upload node'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('says which local file is missing', async () => { + const automation: Automation = { + name: 'it-upload-missing', + params: [{ name: 'host', kind: 'host' }], + nodes: [{ id: 'u', snippetId: '', upload: { from: join(tmpdir(), 'no-such-file.tar.gz'), to: '/tmp/' }, label: 'upload', continueOnError: false, target: 'remote' }], + edges: [] + }; + const [result] = await runAutomation(automation, new Map(), { host: 'ssh-test-target' }, deps()); + expect(result.status).toBe('failed'); + expect(result.error).toContain('no such file on this computer'); + }); +}); + describe('snippet engine against the test target', () => { it('a local node feeds its output into a remote node over a real SSH connection', async () => { const local: Snippet = { id: 'local', name: 'Local', kind: 'local', command: 'echo build-123', timeoutSecs: 30 }; diff --git a/packages/electron/src/core/automation/engine.test.ts b/packages/electron/src/core/automation/engine.test.ts index bd0180f..319d101 100644 --- a/packages/electron/src/core/automation/engine.test.ts +++ b/packages/electron/src/core/automation/engine.test.ts @@ -5,6 +5,7 @@ import { runAutomation, substituteTemplate, topoOrder, + uploadDestination, validateAutomation, type RunAutomationDeps } from './engine.js'; @@ -240,7 +241,7 @@ describe('runAutomation', () => { function deps(overrides: Partial = {}): RunAutomationDeps { return { runLocal: async (command) => ({ output: `ran: ${command}`, ok: true }), - connectHost: async () => ({ runShell: async () => ({ output: '', ok: true }), disconnect: () => {} }), + connectHost: async () => ({ runShell: async () => ({ output: '', ok: true }), upload: async () => {}, disconnect: () => {} }), ...overrides }; } @@ -490,3 +491,89 @@ describe('runAutomation', () => { expect(results[0].error).toMatch(/no host parameter value/); }); }); + +describe('upload nodes', () => { + const pack = snippet({ id: 'pack', name: 'Pack', command: 'docker save -o image.tar {{params.image}} && tar -czf image.tar.gz image.tar' }); + const load = snippet({ id: 'load', name: 'Load', command: 'docker load < {{nodes.upload.output}}' }); + const params: AutomationParam[] = [...hostParam, { name: 'image', kind: 'text' }, { name: 'dir', kind: 'text' }]; + const flow = automation( + [ + node({ id: 'n1', snippetId: 'pack', label: 'pack' }), + node({ id: 'n2', snippetId: '', label: 'upload', target: 'remote', upload: { from: 'image.tar.gz', to: '{{params.dir}}/' } }), + node({ id: 'n3', snippetId: 'load', label: 'load', target: 'remote' }) + ], + [ + ['n1', 'n2'], + ['n2', 'n3'] + ], + params + ); + const library = new Map([ + ['pack', pack], + ['load', load] + ]); + + it('validates: needs no snippet, but both paths, the host, and known references', () => { + expect(validateAutomation(flow, library)).toEqual([]); + const bad = automation( + [node({ id: 'u', snippetId: '', label: 'u', target: 'local', upload: { from: ' ', to: '{{params.nope}}' } })], + [], + hostParam + ); + expect(validateAutomation(bad, library)).toEqual([ + 'upload "u" needs a file to upload', + 'upload "u" must target the host', + 'node "u" references unknown parameter "nope"' + ]); + }); + + it('uploads over the host connection, and its output is where the file landed', async () => { + const uploads: Array<[string, string]> = []; + const commands: string[] = []; + let connects = 0; + const results = await runAutomation(flow, library, { host: 'web-1', image: 'nginx:1.27', dir: '/tmp' }, { + runLocal: async () => ({ output: '', ok: true }), + connectHost: async () => { + connects += 1; + return { + runShell: async (cmd) => { + commands.push(cmd); + return { output: 'Loaded image', ok: true }; + }, + upload: async (from, to) => { + uploads.push([from, to]); + }, + disconnect: () => {} + }; + } + }); + expect(uploads).toEqual([['image.tar.gz', '/tmp/image.tar.gz']]); + expect(results.map((r) => r.status)).toEqual(['success', 'success', 'success']); + expect(results[1].output).toBe('/tmp/image.tar.gz'); + expect(commands).toEqual(['docker load < /tmp/image.tar.gz']); + expect(connects).toBe(1); + }); + + it('fails the node with the reason, and skips what depends on it', async () => { + const results = await runAutomation(flow, library, { host: 'web-1', image: 'nginx', dir: '/tmp' }, { + runLocal: async () => ({ output: '', ok: true }), + connectHost: async () => ({ + runShell: async () => ({ output: '', ok: true }), + upload: async () => { + throw new Error('no such file on this computer: C:\\Users\\me\\image.tar.gz'); + }, + disconnect: () => {} + }) + }); + expect(results.map((r) => r.status)).toEqual(['success', 'failed', 'skipped']); + expect(results[1].error).toMatch(/no such file on this computer/); + }); +}); + +describe('uploadDestination', () => { + it('keeps the file name for a folder ending in /, from either kind of local path', () => { + expect(uploadDestination('C:\\Users\\me\\image.tar.gz', '/tmp/')).toBe('/tmp/image.tar.gz'); + expect(uploadDestination('~/build/app.tgz', '/srv/')).toBe('/srv/app.tgz'); + expect(uploadDestination('image.tar.gz', '/tmp/renamed.tar.gz')).toBe('/tmp/renamed.tar.gz'); + }); +}); diff --git a/packages/electron/src/core/automation/engine.ts b/packages/electron/src/core/automation/engine.ts index 4c1d1a7..b64b1bd 100644 --- a/packages/electron/src/core/automation/engine.ts +++ b/packages/electron/src/core/automation/engine.ts @@ -68,6 +68,22 @@ function hostParamOf(automation: Automation): { name: string } | undefined { return automation.params.find((p) => p.kind === 'host'); } +/** Everything in a node that templates are substituted into: its snippet's command, or + * an upload's two paths. Undefined for a node whose snippet is gone. */ +function templatedText(node: AutomationNode, snippetsById: Map): string | undefined { + if (node.upload !== undefined) return `${node.upload.from}\n${node.upload.to}`; + return snippetsById.get(node.snippetId)?.command; +} + +/** Where an upload lands: `to` itself, or β€” when `to` ends in `/` β€” that folder plus the + * local file's name. The host is POSIX; the local path may use either separator. */ +export function uploadDestination(from: string, to: string): string { + const dest = to.trim(); + if (!dest.endsWith('/')) return dest; + const name = from.trim().split(/[\\/]/).filter(Boolean).pop() ?? ''; + return `${dest}${name}`; +} + /** Save-time structural validation β€” not execution. Returns a list of problem * strings (empty means valid): an unknown `snippetId`, a duplicate label or * parameter name, more than one `'host'`-kind parameter, a remote node with no host @@ -84,8 +100,11 @@ export function validateAutomation(automation: Automation, snippetsById: Map(); for (const node of automation.nodes) { - const snippet = snippetsById.get(node.snippetId); - if (snippet === undefined) { + if (node.upload !== undefined) { + if (!node.upload.from.trim()) problems.push(`upload "${node.label}" needs a file to upload`); + if (!node.upload.to.trim()) problems.push(`upload "${node.label}" needs a destination on the host`); + if (node.target !== 'remote') problems.push(`upload "${node.label}" must target the host`); + } else if (snippetsById.get(node.snippetId) === undefined) { problems.push(`node "${node.label}" references an unknown snippet`); } labelCounts.set(node.label, (labelCounts.get(node.label) ?? 0) + 1); @@ -122,12 +141,12 @@ export function validateAutomation(automation: Automation, snippetsById: Map nodeById.get(id)?.label) ); - for (const match of snippet.command.matchAll(TEMPLATE_REF)) { + for (const match of text.matchAll(TEMPLATE_REF)) { const ref = match[1]; if (!labelToNode.has(ref)) { problems.push(`node "${node.label}" references unknown label "${ref}"`); @@ -135,7 +154,7 @@ export function validateAutomation(automation: Automation, snippetsById: Map; + /** Copies the local file `from` to `to` on the host; rejects with why it failed. */ + upload(from: string, to: string): Promise; disconnect(): void; } @@ -225,6 +246,16 @@ export async function runAutomation( const resultsById = new Map(); const connections = new Map(); + /** The host's connection, opened on first use and shared by every node after. */ + async function connectionFor(hostName: string): Promise { + let connection = connections.get(hostName); + if (connection === undefined) { + connection = await deps.connectHost(hostName); + connections.set(hostName, connection); + } + return connection; + } + function settle(nodeId: string, result: NodeResult): void { resultsById.set(nodeId, result); onProgress?.({ kind: 'nodeResult', result }); @@ -235,7 +266,7 @@ export async function runAutomation( const node = nodeById.get(nodeId)!; const snippet = snippetsById.get(node.snippetId); - if (snippet === undefined) { + if (snippet === undefined && node.upload === undefined) { settle(nodeId, { nodeId, label: node.label, status: 'failed', output: '', error: 'snippet no longer exists', durationMs: 0 }); continue; } @@ -255,22 +286,27 @@ export async function runAutomation( onProgress?.({ kind: 'nodeStarted', nodeId, label: node.label }); const predecessorsByLabel = new Map(predecessorIds.map((id) => [nodeById.get(id)!.label, resultsById.get(id)!])); - const timeoutMs = snippet.timeoutSecs * 1000; const startedAt = Date.now(); let exec: { output: string; ok: boolean; error?: string }; try { - const command = substituteTemplate(snippet.command, predecessorsByLabel, paramValues); - if (node.target === 'local') { - exec = await deps.runLocal(command, timeoutMs); + if (node.upload !== undefined) { + // No timeout: a big image takes as long as the line allows, and a stalled + // transfer fails on the SSH connection's own keepalive. + const from = substituteTemplate(node.upload.from, predecessorsByLabel, paramValues).trim(); + const to = uploadDestination(from, substituteTemplate(node.upload.to, predecessorsByLabel, paramValues)); + const connection = await connectionFor(nodeHostName!); + await connection.upload(from, to); + // The output is where it landed, so the next node can use it as is: + // `docker load < {{nodes.upload.output}}`. + exec = { output: to, ok: true }; + } else if (node.target === 'local') { + const command = substituteTemplate(snippet!.command, predecessorsByLabel, paramValues); + exec = await deps.runLocal(command, snippet!.timeoutSecs * 1000); } else { - const hostName = nodeHostName!; - let connection = connections.get(hostName); - if (connection === undefined) { - connection = await deps.connectHost(hostName); - connections.set(hostName, connection); - } - exec = await connection.runShell(command, timeoutMs); + const command = substituteTemplate(snippet!.command, predecessorsByLabel, paramValues); + const connection = await connectionFor(nodeHostName!); + exec = await connection.runShell(command, snippet!.timeoutSecs * 1000); } } catch (e) { exec = { output: '', ok: false, error: (e as Error).message }; diff --git a/packages/electron/src/core/automation/localExec.test.ts b/packages/electron/src/core/automation/localExec.test.ts index ec244ce..9833a32 100644 --- a/packages/electron/src/core/automation/localExec.test.ts +++ b/packages/electron/src/core/automation/localExec.test.ts @@ -1,8 +1,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { runLocalCommand } from './localExec.js'; +import { localUploadPath, runLocalCommand } from './localExec.js'; // Shells out via child_process.exec (real shell semantics β€” pipes, &&, …), so these // tests run real (tiny, fast, cross-platform) commands rather than mocking anything. @@ -55,3 +55,13 @@ describe('runLocalCommand', () => { expect(elapsed).toBeLessThan(5000); }); }); + +describe('localUploadPath', () => { + it('reads a relative path and ~ from the home folder, keeps an absolute one', () => { + const home = join('/', 'home', 'me'); + expect(localUploadPath('image.tar.gz', home)).toBe(join(home, 'image.tar.gz')); + expect(localUploadPath(' ~/build/app.tgz ', home)).toBe(join(home, 'build', 'app.tgz')); + const absolute = resolve('/data/app.tgz'); + expect(localUploadPath(absolute, home)).toBe(absolute); + }); +}); diff --git a/packages/electron/src/core/automation/localExec.ts b/packages/electron/src/core/automation/localExec.ts index 290fe67..3d87594 100644 --- a/packages/electron/src/core/automation/localExec.ts +++ b/packages/electron/src/core/automation/localExec.ts @@ -1,4 +1,25 @@ import { exec } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; + +/** A local file an upload node names: absolute as given, `~/…` in the home folder, and + * anything else relative to the home folder too β€” where local nodes run (see + * `runLocalCommand`), so `image.tar.gz` is the file a previous node just wrote. */ +export function localUploadPath(from: string, home: string = homedir()): string { + const p = from.trim(); + if (p === '~') return home; + if (p.startsWith('~/') || p.startsWith('~\\')) return join(home, p.slice(2)); + return isAbsolute(p) ? p : join(home, p); +} + +/** Checks the file an upload node is about to send, so a missing one fails with its + * full path rather than SFTP's bare "No such file". */ +export async function checkUploadSource(path: string): Promise { + const info = await stat(path).catch(() => undefined); + if (info === undefined) throw new Error(`no such file on this computer: ${path}`); + if (!info.isFile()) throw new Error(`not a file: ${path}`); +} /** * Runs a shell command on the local machine for a Snippet "local" node. Unlike @@ -26,7 +47,10 @@ export async function runLocalCommand( return new Promise((resolve) => { exec( command, - { timeout: timeoutMs, killSignal: 'SIGTERM', windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, + // In the home folder: the app's own working directory is wherever it was started + // from β€” the install folder, often not writable β€” so `docker save -o image.tar` + // had nowhere sensible to go. An upload node's relative path means the same folder. + { cwd: homedir(), timeout: timeoutMs, killSignal: 'SIGTERM', windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { const output = stdout + stderr; if (err) { diff --git a/packages/electron/src/core/automation/types.ts b/packages/electron/src/core/automation/types.ts index 202199e..b805691 100644 --- a/packages/electron/src/core/automation/types.ts +++ b/packages/electron/src/core/automation/types.ts @@ -40,10 +40,24 @@ export interface AutomationParam { * `'host'` param resolves at run time. */ export type NodeTarget = 'local' | 'remote'; +/** A built-in step instead of a snippet: copies a file from this machine to the + * automation's host over the app's own SFTP connection (its saved password, + * 1Password, jump hosts, known host keys). Both paths may use `{{params.}}` + * and `{{nodes.