Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
16 changes: 16 additions & 0 deletions packages/electron/src/core/automation/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
10 changes: 8 additions & 2 deletions packages/electron/src/core/automation/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function buildAutomationBundle(automation: Automation, snippetsById: Map<
const seen = new Set<string>();
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`);
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 44 additions & 1 deletion packages/electron/src/core/automation/engine.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 };
Expand Down
89 changes: 88 additions & 1 deletion packages/electron/src/core/automation/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
runAutomation,
substituteTemplate,
topoOrder,
uploadDestination,
validateAutomation,
type RunAutomationDeps
} from './engine.js';
Expand Down Expand Up @@ -240,7 +241,7 @@ describe('runAutomation', () => {
function deps(overrides: Partial<RunAutomationDeps> = {}): 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
};
}
Expand Down Expand Up @@ -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');
});
});
72 changes: 54 additions & 18 deletions packages/electron/src/core/automation/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, Snippet>): 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
Expand All @@ -84,8 +100,11 @@ export function validateAutomation(automation: Automation, snippetsById: Map<str
const labelCounts = new Map<string, number>();

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);
Expand Down Expand Up @@ -122,20 +141,20 @@ export function validateAutomation(automation: Automation, snippetsById: Map<str

const predecessorsOf = predecessorMap(automation);
for (const node of automation.nodes) {
const snippet = snippetsById.get(node.snippetId);
if (snippet === undefined) continue;
const text = templatedText(node, snippetsById);
if (text === undefined) continue;
const directPredecessorLabels = new Set(
(predecessorsOf.get(node.id) ?? []).map((id) => 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}"`);
} else if (!directPredecessorLabels.has(ref)) {
problems.push(`node "${node.label}" references "${ref}", which is not a direct dependency (add an edge from it)`);
}
}
for (const match of snippet.command.matchAll(PARAM_REF)) {
for (const match of text.matchAll(PARAM_REF)) {
const ref = match[1];
if (!paramNames.has(ref)) {
problems.push(`node "${node.label}" references unknown parameter "${ref}"`);
Expand Down Expand Up @@ -186,6 +205,8 @@ export function substituteTemplate(

export interface RunAutomationConnection {
runShell(cmd: string, timeoutMs: number): Promise<{ output: string; ok: boolean; error?: string }>;
/** Copies the local file `from` to `to` on the host; rejects with why it failed. */
upload(from: string, to: string): Promise<void>;
disconnect(): void;
}

Expand Down Expand Up @@ -225,6 +246,16 @@ export async function runAutomation(
const resultsById = new Map<string, NodeResult>();
const connections = new Map<string, RunAutomationConnection>();

/** The host's connection, opened on first use and shared by every node after. */
async function connectionFor(hostName: string): Promise<RunAutomationConnection> {
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 });
Expand All @@ -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;
}
Expand All @@ -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 };
Expand Down
14 changes: 12 additions & 2 deletions packages/electron/src/core/automation/localExec.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
});
});
Loading
Loading