@realtimex/rtxexec 0.2.0 → 0.3.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/README.md +75 -0
- package/package.json +1 -1
- package/src/browser.js +55 -9
- package/src/cli.js +13 -1
- package/src/client.js +7 -3
- package/src/ssh.js +45 -0
package/README.md
CHANGED
|
@@ -78,3 +78,78 @@ The destination/browser necessarily receives the values. Do not take snapshots,
|
|
|
78
78
|
screenshots, recordings, DOM-value reads, or network traces while credentials are
|
|
79
79
|
present. A page may display a username after login. This reduces accidental exposure;
|
|
80
80
|
it does not prevent intentional local extraction or malicious website behavior.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
## Structured credential items
|
|
84
|
+
|
|
85
|
+
RealTimeX stores Login, Card, Identity, SSH key and Secure note items. Management
|
|
86
|
+
stays in Settings or the moderator CLI; rtxexec only uses saved values.
|
|
87
|
+
List metadata to discover `reference` and `fieldNames`. To select a field:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
rtxexec --env API_TOKEN=secret://service#apiToken -- program
|
|
91
|
+
rtxexec --stdin secret://note#notes -- program
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A Login can contain just a password, the built-in API key / Token (`apiToken`),
|
|
95
|
+
or custom fields; a username and website are optional for terminal use. New Login
|
|
96
|
+
defaults to password when present, otherwise `apiToken`; Secure note defaults to
|
|
97
|
+
notes. Existing scalar references keep their value. An explicit move into Password
|
|
98
|
+
or API key / Token preserves both default and `#value` references, including after
|
|
99
|
+
rotation.
|
|
100
|
+
Use explicit field references for Card, Identity and SSH items.
|
|
101
|
+
|
|
102
|
+
### Fill Card or Identity forms
|
|
103
|
+
|
|
104
|
+
Prepare a page with agent-browser, identify its visible top-level selectors,
|
|
105
|
+
and use the exact CDP target ID from `browser-tabs`:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
rtxexec browser-fill secret://card --cdp 9235 --tab TARGET_ID \
|
|
109
|
+
--field 'number=#card-number' --field 'securityCode=#cvv'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Only mapped fields are requested. The item's permitted origins and workspace
|
|
113
|
+
scope are enforced. Inputs, textareas and selects are supported; iframe forms
|
|
114
|
+
are not. This command never submits. Check non-secret status and perform any
|
|
115
|
+
submission separately within the authorized task. Avoid snapshots, recordings
|
|
116
|
+
and field-value reads while saved data is in the page. `browser-login` remains
|
|
117
|
+
available and now requests only the username/password fields selected.
|
|
118
|
+
|
|
119
|
+
### SSH and Git
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
rtxexec ssh secret://deploy-key -- ssh user@host
|
|
123
|
+
rtxexec ssh secret://deploy-key -- git fetch origin
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Requires OpenSSH (and Git for Git commands). The app unlocks the saved key for
|
|
127
|
+
this execution. rtxexec writes it to a restricted temporary directory, supplies
|
|
128
|
+
the path to SSH or Git, and removes the directory when the child finishes or
|
|
129
|
+
fails. Unix uses directory mode 0700 and file mode 0600; Windows requires icacls
|
|
130
|
+
to restrict the directory before writing. Existing host-key verification stays
|
|
131
|
+
in effect. SIGKILL or a machine crash may prevent cleanup. The CLI keeps no
|
|
132
|
+
persistent vault or secret cache. Output masking is best effort; this is an
|
|
133
|
+
accidental-disclosure safeguard, not a boundary against deliberate local access.
|
|
134
|
+
|
|
135
|
+
## Linked SSO logins
|
|
136
|
+
|
|
137
|
+
A Login can keep its normal username/password and link multiple saved Login items
|
|
138
|
+
for SSO. Management uses `ssoCredentialIds` (moderator flag
|
|
139
|
+
`--sso-credential-ids id1,id2`; an empty flag clears links). Names and descriptions
|
|
140
|
+
identify the provider and account; no separate provider instructions are needed.
|
|
141
|
+
|
|
142
|
+
Use agent-browser to choose the matching sign-in button and reach the provider.
|
|
143
|
+
Then explicitly select the linked Login:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
rtxexec browser-login secret://rtgit --sso secret://google \
|
|
147
|
+
--cdp 9235 --tab TARGET_ID \
|
|
148
|
+
--username-selector '#username' --password-selector '#password'
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The server checks both items' enabled state and workspace scope, and the provider's
|
|
152
|
+
allowed website origin. It records usage for both without copying values into the
|
|
153
|
+
site item. Omitting `--sso` uses normal credentials; selection never automatically
|
|
154
|
+
follows further links. Ask when the provider/account is ambiguous, and handle
|
|
155
|
+
already signed-in sessions or MFA through the normal browser workflow.
|
package/package.json
CHANGED
package/src/browser.js
CHANGED
|
@@ -6,15 +6,24 @@ export function parseBrowserArguments(argv) {
|
|
|
6
6
|
const options = {};
|
|
7
7
|
let reference;
|
|
8
8
|
let i = 1;
|
|
9
|
-
if (command
|
|
10
|
-
const allowed = command === 'browser-tabs' ? ['cdp'] : ['cdp', 'tab', 'username-selector', 'password-selector', 'submit-selector'];
|
|
9
|
+
if (command !== 'browser-tabs') reference = argv[i++];
|
|
10
|
+
const allowed = command === 'browser-tabs' ? ['cdp'] : command === 'browser-fill' ? ['cdp', 'tab', 'field', 'sso'] : ['cdp', 'tab', 'username-selector', 'password-selector', 'submit-selector', 'sso'];
|
|
11
11
|
for (; i < argv.length; i += 2) {
|
|
12
12
|
const key = argv[i]?.replace(/^--/, '');
|
|
13
|
-
if (!argv[i]?.startsWith('--') || !allowed.includes(key) || options[key] !== undefined || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new UsageError('Invalid browser option. See rtxexec --help.');
|
|
14
|
-
|
|
13
|
+
if (!argv[i]?.startsWith('--') || !allowed.includes(key) || (key !== 'field' && options[key] !== undefined) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new UsageError('Invalid browser option. See rtxexec --help.');
|
|
14
|
+
if (key === 'field') {
|
|
15
|
+
const value = argv[i + 1]; const split = value.indexOf('=');
|
|
16
|
+
const field = value.slice(0, split); const selector = value.slice(split + 1);
|
|
17
|
+
if (split < 1 || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(field) || !selector || selector.length > 4096 || ['constructor', 'prototype', '__proto__'].includes(field)) throw new UsageError('Use --field fieldName=selector.');
|
|
18
|
+
options.fields ||= [];
|
|
19
|
+
if (options.fields.some((entry) => entry.name === field || entry.selector === selector) || options.fields.length >= 32) throw new UsageError('Use up to 32 distinct fields and selectors.');
|
|
20
|
+
options.fields.push({ name: field, selector });
|
|
21
|
+
} else options[key] = argv[i + 1];
|
|
15
22
|
}
|
|
16
23
|
if (!/^\d{1,5}$/.test(options.cdp || '') || Number(options.cdp) < 1 || Number(options.cdp) > 65535) throw new UsageError('Provide a local CDP port with --cdp.');
|
|
17
24
|
if (command === 'browser-login' && (!reference?.startsWith('secret://') || !/^[a-zA-Z0-9_-]{1,128}$/.test(options.tab || '') || (!options['username-selector'] && !options['password-selector']))) throw new UsageError('Provide a secret reference, --tab from browser-tabs, and at least one username/password selector.');
|
|
25
|
+
if (command === 'browser-fill' && (!reference?.startsWith('secret://') || reference.includes('#') || !/^[a-zA-Z0-9_-]{1,128}$/.test(options.tab || '') || !options.fields?.length)) throw new UsageError('Provide an item reference, --tab and at least one --field name=selector.');
|
|
26
|
+
if (options.sso && (!options.sso.startsWith('secret://') || options.sso.includes('#') || options.sso.length > 1024)) throw new UsageError('Use --sso secret://provider with a linked Login item.');
|
|
18
27
|
return { command, reference, ...options };
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -105,6 +114,38 @@ export function fillLogin(username, password, selectors, expectedOrigin) {
|
|
|
105
114
|
return { status: submit ? 'submitted' : 'filled' };
|
|
106
115
|
}
|
|
107
116
|
|
|
117
|
+
|
|
118
|
+
// Fill-only: submission remains an explicit, separate browser action.
|
|
119
|
+
export function fillFields(values, selectors, expectedOrigin) {
|
|
120
|
+
const usable = (element) => {
|
|
121
|
+
if (!element || !element.isConnected || element.disabled || element.matches(':disabled') || element.readOnly || !element.getClientRects().length) return false;
|
|
122
|
+
if (getComputedStyle(element).visibility !== 'visible') return false;
|
|
123
|
+
for (let node = element; node; node = node.parentElement) if (Number(getComputedStyle(node).opacity) === 0) return false;
|
|
124
|
+
return !element.form || new URL(element.form.action || location.href).origin === expectedOrigin;
|
|
125
|
+
};
|
|
126
|
+
if (location.origin !== expectedOrigin) return { error: 'origin' };
|
|
127
|
+
const fields = [];
|
|
128
|
+
for (const { name, selector } of selectors) {
|
|
129
|
+
const matches = document.querySelectorAll(selector);
|
|
130
|
+
const element = matches.length === 1 ? matches[0] : null;
|
|
131
|
+
if (!usable(element) || !(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)) return { error: 'field' };
|
|
132
|
+
if (element instanceof HTMLInputElement && ['file', 'hidden', 'checkbox', 'radio', 'button', 'submit', 'reset', 'image'].includes(element.type)) return { error: 'field' };
|
|
133
|
+
if (name === 'password' && (!(element instanceof HTMLInputElement) || element.type !== 'password')) return { error: 'password-field' };
|
|
134
|
+
if (element instanceof HTMLSelectElement && !Array.from(element.options).some(option => option.value === values[name] && !option.disabled)) return { error: 'option' };
|
|
135
|
+
if (fields.some(entry => entry.element === element)) return { error: 'duplicate' };
|
|
136
|
+
fields.push({ element, name, type: element.type });
|
|
137
|
+
}
|
|
138
|
+
for (const { element, name, type } of fields) {
|
|
139
|
+
if (location.origin !== expectedOrigin || !usable(element) || element.type !== type) return { error: 'changed' };
|
|
140
|
+
const prototype = element instanceof HTMLInputElement ? HTMLInputElement.prototype : element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLTextAreaElement.prototype;
|
|
141
|
+
Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, values[name]);
|
|
142
|
+
if (element.value !== values[name]) return { error: 'value' };
|
|
143
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
144
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
145
|
+
}
|
|
146
|
+
return { status: 'filled' };
|
|
147
|
+
}
|
|
148
|
+
|
|
108
149
|
export async function runBrowser(plan, env, { targets = browserTargets, connect = connectCdp, resolver = resolveSecrets } = {}) {
|
|
109
150
|
const pages = await targets(plan.cdp);
|
|
110
151
|
if (plan.command === 'browser-tabs') return { tabs: pages.map(({ id, url }) => { const clean = new URL(url); clean.search = ''; clean.hash = ''; clean.username = ''; clean.password = ''; return { id, url: clean.href }; }) };
|
|
@@ -120,14 +161,19 @@ export async function runBrowser(plan, env, { targets = browserTargets, connect
|
|
|
120
161
|
origin = url.origin;
|
|
121
162
|
} catch { throw new UsageError('Selected page is not ready. Wait for navigation and run browser-tabs again.'); }
|
|
122
163
|
const { executionContextId } = await cdp.send('Page.createIsolatedWorld', { frameId: frameTree.frame.id, worldName: 'rtxexec-login' });
|
|
123
|
-
const
|
|
164
|
+
const requested = plan.command === 'browser-fill' ? plan.fields.map(entry => entry.name) : ['username', 'password'].filter(name => plan[`${name}-selector`]);
|
|
165
|
+
const values = await resolver({ references: [plan.reference], command: 'rtxexec', browser: { origin, targetId: target.id, fields: requested, ...(plan.sso ? { ssoReference: plan.sso } : {}) } }, env);
|
|
124
166
|
const credential = values[0];
|
|
125
|
-
|
|
167
|
+
const fields = credential?.fields || credential;
|
|
168
|
+
if (requested.some(name => typeof fields?.[name] !== 'string' || !fields[name]) || !Array.isArray(credential?.allowedOrigins) || !credential.allowedOrigins.includes(origin)) throw new UsageError('RealTimeX did not authorize the requested fields for this website.');
|
|
169
|
+
const args = plan.command === 'browser-fill'
|
|
170
|
+
? [Object.fromEntries(requested.map(name => [name, fields[name]])), plan.fields, origin]
|
|
171
|
+
: [fields.username || '', fields.password || '', { username: plan['username-selector'], password: plan['password-selector'], submit: plan['submit-selector'] }, origin];
|
|
126
172
|
const result = await cdp.send('Runtime.callFunctionOn', {
|
|
127
|
-
executionContextId, functionDeclaration: fillLogin.toString(), returnByValue: true,
|
|
128
|
-
arguments:
|
|
173
|
+
executionContextId, functionDeclaration: (plan.command === 'browser-fill' ? fillFields : fillLogin).toString(), returnByValue: true,
|
|
174
|
+
arguments: args.map((value) => ({ value })),
|
|
129
175
|
});
|
|
130
|
-
if (result.exceptionDetails || !['filled', 'submitted'].includes(result.result?.value?.status)) throw new UsageError('
|
|
176
|
+
if (result.exceptionDetails || !['filled', 'submitted'].includes(result.result?.value?.status)) throw new UsageError('Form fill was not completed. Check the selected page, origin and visible form selectors; values were not printed.');
|
|
131
177
|
return { status: result.result.value.status, targetId: target.id, origin };
|
|
132
178
|
} finally { cdp.close(); }
|
|
133
179
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseBrowserArguments, runBrowser } from "./browser.js";
|
|
2
|
+
import { parseSshArguments, runSsh } from "./ssh.js";
|
|
2
3
|
import { UsageError } from "./error.js";
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
4
5
|
import { constants } from 'node:os';
|
|
@@ -19,10 +20,20 @@ Browser use (Node.js 22+, existing local CDP browser):
|
|
|
19
20
|
rtxexec browser-tabs --cdp <port>
|
|
20
21
|
rtxexec browser-login secret://name --cdp <port> --tab <CDP-target-id>
|
|
21
22
|
--username-selector <css> --password-selector <css> [--submit-selector <css>]
|
|
23
|
+
rtxexec browser-fill secret://name --cdp <port> --tab <CDP-target-id>
|
|
24
|
+
--field number="#card-number" --field securityCode="#cvv"
|
|
25
|
+
Add --sso secret://provider to explicitly use a linked SSO Login.
|
|
26
|
+
browser-fill fills only; use a separate authorized action to submit.
|
|
27
|
+
Named terminal fields use secret://name#field (for example #apiToken).
|
|
22
28
|
Omit either field selector for a multi-step login. No navigation or screenshots.
|
|
23
29
|
Supports top-level forms; use agent-browser to prepare the page first.
|
|
24
30
|
Filled/submitted does not mean authenticated: verify a non-secret success state.
|
|
25
31
|
|
|
32
|
+
SSH/Git use:
|
|
33
|
+
rtxexec ssh secret://name -- ssh user@host
|
|
34
|
+
rtxexec ssh secret://name -- git fetch origin
|
|
35
|
+
Requires OpenSSH. Creates an owner-only temporary key, removed after exit.
|
|
36
|
+
|
|
26
37
|
Manage secrets with realtimex-pp-cli or Settings > Secrets.
|
|
27
38
|
Requires the running app and its terminal-session environment. No secret cache.
|
|
28
39
|
Runs executables directly, without a shell. Output is UTF-8 with known values
|
|
@@ -67,11 +78,12 @@ export async function execute(plan, injected, { stdout = process.stdout, stderr
|
|
|
67
78
|
|
|
68
79
|
export async function main(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, resolver = resolveSecrets } = {}) {
|
|
69
80
|
try {
|
|
70
|
-
if (['browser-tabs', 'browser-login'].includes(argv[0])) {
|
|
81
|
+
if (['browser-tabs', 'browser-login', 'browser-fill'].includes(argv[0])) {
|
|
71
82
|
const result = await runBrowser(parseBrowserArguments(argv), env, { resolver });
|
|
72
83
|
stdout.write(`${JSON.stringify(result)}\n`);
|
|
73
84
|
return 0;
|
|
74
85
|
}
|
|
86
|
+
if (argv[0] === "ssh") return await runSsh(parseSshArguments(argv), env, (plan, injected) => execute(plan, injected, { stdout, stderr }), { resolver });
|
|
75
87
|
const plan = parseArguments(argv);
|
|
76
88
|
if (plan.help) { stdout.write(help); return 0; }
|
|
77
89
|
if (plan.version) { stdout.write(`${JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version}\n`); return 0; }
|
package/src/client.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function resolveSecrets(plan, env = process.env, fetchImpl = fetch)
|
|
|
23
23
|
response = await fetchImpl(url, {
|
|
24
24
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
25
25
|
headers: { Authorization: `RealtimeX-Terminal ${token}`, 'Content-Type': 'application/json' },
|
|
26
|
-
body: JSON.stringify({ references: plan.references, executable: path.win32.basename(path.basename(plan.command)), ...(plan.browser ? { browser: plan.browser } : {}) }),
|
|
26
|
+
body: JSON.stringify({ references: plan.references, executable: path.win32.basename(path.basename(plan.command)), ...(plan.browser ? { browser: plan.browser } : {}), ...(plan.ssh ? { ssh: true } : {}) }),
|
|
27
27
|
});
|
|
28
28
|
} catch { throw new UsageError('Could not reach RealTimeX. Check that the app is running; no command was launched.'); }
|
|
29
29
|
let text = '';
|
|
@@ -43,8 +43,12 @@ export async function resolveSecrets(plan, env = process.env, fetchImpl = fetch)
|
|
|
43
43
|
try { body = JSON.parse(text); } catch { throw new UsageError('Invalid RealTimeX response; no command was launched.'); }
|
|
44
44
|
if (!response.ok || body.success !== true) {
|
|
45
45
|
const messages = {
|
|
46
|
-
SECRET_ORIGIN_DENIED: 'This
|
|
47
|
-
SECRET_LOGIN_REQUIRED: 'Select a Login
|
|
46
|
+
SECRET_ORIGIN_DENIED: 'This item is not allowed on the selected website.',
|
|
47
|
+
SECRET_LOGIN_REQUIRED: 'Select a Login, Card or Identity item for browser fill.',
|
|
48
|
+
SECRET_FIELD_NOT_FOUND: 'The selected field has no saved value.',
|
|
49
|
+
SECRET_SSH_REQUIRED: 'Select an SSH key item.',
|
|
50
|
+
SECRET_SSO_INVALID: 'Select a saved Login linked to this item for SSO.',
|
|
51
|
+
SECRET_SSO_DENIED: 'The linked SSO Login is disabled or unavailable in this workspace.',
|
|
48
52
|
SECRET_BROWSER_REQUIRED: 'Use rtxexec browser-login for Login credentials.',
|
|
49
53
|
SECRET_NOT_FOUND: 'A referenced secret does not exist.',
|
|
50
54
|
SECRET_SCOPE_DENIED: 'A referenced secret is not available in this workspace.',
|
package/src/ssh.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdtemp, writeFile, chmod, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir, userInfo } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { UsageError } from './error.js';
|
|
7
|
+
import { resolveSecrets } from './client.js';
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const quote = value => "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
|
11
|
+
export function parseSshArguments(argv) {
|
|
12
|
+
if (!argv[1]?.startsWith('secret://') || argv[1].includes('#') || argv[2] !== '--' || !argv[3]) throw new UsageError('Use rtxexec ssh secret://name -- ssh|git [arguments].');
|
|
13
|
+
const name = path.win32.basename(path.basename(argv[3])).replace(/\.exe$/i, '').toLowerCase();
|
|
14
|
+
if (!['ssh', 'git'].includes(name)) throw new UsageError('SSH key execution supports ssh and git.');
|
|
15
|
+
return { references: [argv[1]], command: argv[3], args: argv.slice(4), ssh: true, sshCommand: name };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runSsh(plan, env, execute, { resolver = resolveSecrets, root = tmpdir() } = {}) {
|
|
19
|
+
const [credential] = await resolver(plan, env);
|
|
20
|
+
if (typeof credential?.privateKey !== 'string' || !credential.privateKey.includes('-----BEGIN OPENSSH PRIVATE KEY-----') || Buffer.byteLength(credential.privateKey) > 65536) throw new UsageError('RealTimeX did not provide a usable SSH key.');
|
|
21
|
+
let directory;
|
|
22
|
+
try {
|
|
23
|
+
directory = await mkdtemp(path.join(root, 'rtxexec-ssh-'));
|
|
24
|
+
if (process.platform === 'win32') {
|
|
25
|
+
// Secure the empty directory before writing material. Fail closed if ACLs
|
|
26
|
+
// cannot be set (e.g. a non-NTFS temp directory).
|
|
27
|
+
await run('icacls.exe', [directory, '/inheritance:r', '/grant:r', `${userInfo().username}:(OI)(CI)F`], { windowsHide: true });
|
|
28
|
+
} else await chmod(directory, 0o700);
|
|
29
|
+
const keyPath = path.join(directory, 'identity');
|
|
30
|
+
await writeFile(keyPath, credential.privateKey, { mode: 0o600, flag: 'wx' });
|
|
31
|
+
const sshArgs = ['-i', keyPath, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none'];
|
|
32
|
+
const childEnv = { ...env };
|
|
33
|
+
let args = plan.args;
|
|
34
|
+
if (plan.sshCommand === 'ssh') args = [...sshArgs, ...args];
|
|
35
|
+
else {
|
|
36
|
+
// Git consumes this command through its own shell. Only generated paths
|
|
37
|
+
// and fixed options are included, with POSIX quoting (also used by Git for Windows).
|
|
38
|
+
childEnv.GIT_SSH_COMMAND = ['ssh', ...sshArgs.map(value => process.platform === 'win32' ? value.replace(/\\/g, '/') : value)].map(quote).join(' ');
|
|
39
|
+
childEnv.GIT_SSH_VARIANT = 'ssh';
|
|
40
|
+
}
|
|
41
|
+
return await execute(plan, { args, env: childEnv, stdin: undefined, values: [credential.privateKey] });
|
|
42
|
+
} finally {
|
|
43
|
+
if (directory) await rm(directory, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|