@commonlyai/cli 0.1.26 → 0.1.28
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/package.json +1 -1
- package/src/commands/agent.js +15 -2
- package/src/commands/login.js +79 -13
- package/src/lib/api.js +26 -11
- package/src/lib/config.js +4 -1
- package/src/lib/device-login.js +126 -0
- package/src/lib/enforcement.js +6 -2
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1001,8 +1001,9 @@ export const performRun = ({
|
|
|
1001
1001
|
}
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
1004
|
+
let turnResult;
|
|
1004
1005
|
try {
|
|
1005
|
-
|
|
1006
|
+
turnResult = await runTurn({
|
|
1006
1007
|
event,
|
|
1007
1008
|
eventPodId,
|
|
1008
1009
|
prompt: peerFrame ? `${peerFrame}\n\n${prompt}` : prompt,
|
|
@@ -1011,8 +1012,20 @@ export const performRun = ({
|
|
|
1011
1012
|
claimKeeper,
|
|
1012
1013
|
trigger,
|
|
1013
1014
|
});
|
|
1015
|
+
return turnResult;
|
|
1014
1016
|
} finally {
|
|
1015
|
-
|
|
1017
|
+
// A silent, normally completed human wake is an explicit decline, not
|
|
1018
|
+
// a successful answer. Tell the kernel so it can hand the message to
|
|
1019
|
+
// exactly one remaining original listener. Any posted reply, refusal
|
|
1020
|
+
// with a reason, or thrown spawn retains completion/legacy semantics:
|
|
1021
|
+
// re-offering those would duplicate a visible response or defeat normal
|
|
1022
|
+
// at-least-once redelivery after an infrastructure failure.
|
|
1023
|
+
const claimOutcome = event.type === 'message.posted'
|
|
1024
|
+
&& event.payload?.senderIsHuman === true
|
|
1025
|
+
&& turnResult?.outcome === 'no_action' && !turnResult?.reason
|
|
1026
|
+
? 'declined'
|
|
1027
|
+
: (turnResult ? 'completed' : undefined);
|
|
1028
|
+
await claimKeeper?.release(claimOutcome);
|
|
1016
1029
|
}
|
|
1017
1030
|
};
|
|
1018
1031
|
|
package/src/commands/login.js
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createInterface } from 'readline';
|
|
9
|
-
import {
|
|
9
|
+
import { hostname } from 'os';
|
|
10
|
+
import { createClient, login as apiLogin } from '../lib/api.js';
|
|
10
11
|
import { saveInstance } from '../lib/config.js';
|
|
12
|
+
import {
|
|
13
|
+
DeviceLoginCancelledError,
|
|
14
|
+
DeviceLoginDeniedError,
|
|
15
|
+
DeviceLoginExpiredError,
|
|
16
|
+
waitForDeviceAuthorization,
|
|
17
|
+
} from '../lib/device-login.js';
|
|
11
18
|
|
|
12
19
|
const prompt = (rl, question) => new Promise((resolve) => rl.question(question, resolve));
|
|
13
20
|
|
|
@@ -41,6 +48,7 @@ export const registerLogin = (program) => {
|
|
|
41
48
|
.description('Authenticate to a Commonly instance')
|
|
42
49
|
.option('--instance <url>', 'Instance URL (default: https://api.commonly.me)')
|
|
43
50
|
.option('--key <name>', 'Config key to save as (default: "default" or "local")')
|
|
51
|
+
.option('--password', 'Use the legacy email/password prompt instead of device authorization')
|
|
44
52
|
.addHelpText('after', `
|
|
45
53
|
Examples:
|
|
46
54
|
$ commonly login # production (default key)
|
|
@@ -58,26 +66,64 @@ Tokens are stored in ~/.commonly/config.json. Other commands take
|
|
|
58
66
|
const isLocal = instanceUrl.includes('localhost') || instanceUrl.includes('127.0.0.1');
|
|
59
67
|
const configKey = opts.key || (isLocal ? 'local' : 'default');
|
|
60
68
|
|
|
61
|
-
console.log(`Logging in to ${instanceUrl}`);
|
|
62
|
-
|
|
63
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
64
|
-
const email = await prompt(rl, 'Email: ');
|
|
65
|
-
rl.close();
|
|
66
|
-
|
|
67
|
-
const password = await promptSecret('Password: ');
|
|
68
|
-
|
|
69
69
|
try {
|
|
70
|
+
if (!opts.password) {
|
|
71
|
+
const client = createClient({ instance: instanceUrl, token: null });
|
|
72
|
+
const started = await client.post('/api/auth/device/start', {
|
|
73
|
+
clientName: 'commonly-cli',
|
|
74
|
+
clientVersion: program.version(),
|
|
75
|
+
hostname: hostname(),
|
|
76
|
+
});
|
|
77
|
+
const minutes = Math.ceil(started.expiresIn / 60);
|
|
78
|
+
console.log(`Logging in to ${instanceUrl} as a new device.\n`);
|
|
79
|
+
console.log(` Open ${started.verifyUrl}`);
|
|
80
|
+
console.log(` Code ${started.userCode}`);
|
|
81
|
+
console.log(`\nWaiting for approval… (expires in ${minutes}:00) press o to open the browser, q to cancel`);
|
|
82
|
+
const data = await waitForDeviceAuthorization({
|
|
83
|
+
client,
|
|
84
|
+
deviceCode: started.deviceCode,
|
|
85
|
+
userCode: started.userCode,
|
|
86
|
+
verifyUrl: started.verifyUrl,
|
|
87
|
+
interval: started.interval,
|
|
88
|
+
expiresIn: started.expiresIn,
|
|
89
|
+
onStatus: (message) => console.log(message),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
saveInstance({
|
|
93
|
+
key: configKey,
|
|
94
|
+
url: instanceUrl,
|
|
95
|
+
token: data.token,
|
|
96
|
+
userId: data.userId,
|
|
97
|
+
username: data.username,
|
|
98
|
+
tokenType: 'device',
|
|
99
|
+
});
|
|
100
|
+
const devicesUrl = new URL('/settings/devices', started.verifyUrl).toString();
|
|
101
|
+
console.log(`\n✓ Authorized as @${data.username} on ${configKey} (${instanceUrl})`);
|
|
102
|
+
console.log(` Token saved to ~/.commonly/config.json · manage devices at ${devicesUrl}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(`Logging in to ${instanceUrl}`);
|
|
107
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
108
|
+
const email = await prompt(rl, 'Email: ');
|
|
109
|
+
rl.close();
|
|
110
|
+
const password = await promptSecret('Password: ');
|
|
70
111
|
const data = await apiLogin(instanceUrl, email.trim(), password);
|
|
71
112
|
const token = data.token;
|
|
72
113
|
const userId = data.user?._id || data.user?.id;
|
|
73
114
|
const username = data.user?.username;
|
|
74
115
|
|
|
75
|
-
saveInstance({ key: configKey, url: instanceUrl, token, userId, username });
|
|
116
|
+
saveInstance({ key: configKey, url: instanceUrl, token, userId, username, tokenType: 'jwt' });
|
|
76
117
|
|
|
77
118
|
console.log(`\nLogged in as ${username} (${configKey})`);
|
|
78
119
|
console.log(`Token saved to ~/.commonly/config.json`);
|
|
79
120
|
} catch (err) {
|
|
80
|
-
|
|
121
|
+
const message = err instanceof DeviceLoginExpiredError
|
|
122
|
+
? `Code expired after 10 minutes. Run commonly login --instance ${configKey} for a new code.`
|
|
123
|
+
: err instanceof DeviceLoginCancelledError || err instanceof DeviceLoginDeniedError
|
|
124
|
+
? err.message
|
|
125
|
+
: `Login failed: ${err.message}`;
|
|
126
|
+
console.error(message);
|
|
81
127
|
process.exit(1);
|
|
82
128
|
}
|
|
83
129
|
});
|
|
@@ -97,9 +143,29 @@ export const registerWhoami = (program) => {
|
|
|
97
143
|
return;
|
|
98
144
|
}
|
|
99
145
|
|
|
100
|
-
instances.forEach(({ key, url, username, active,
|
|
146
|
+
instances.forEach(({ key, url, username, active, token, tokenType }) => {
|
|
101
147
|
const marker = active ? '→' : ' ';
|
|
102
|
-
console.log(`${marker} ${key} ${username || '?'}@${url} (
|
|
148
|
+
console.log(`${marker} ${key} ${username || '?'}@${url} (${formatTokenStatus(token, tokenType, Date.now(), key)})`);
|
|
103
149
|
});
|
|
104
150
|
});
|
|
105
151
|
};
|
|
152
|
+
|
|
153
|
+
const decodeJwtExpiry = (token) => {
|
|
154
|
+
if (typeof token !== 'string' || token.split('.').length !== 3) return null;
|
|
155
|
+
try {
|
|
156
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
157
|
+
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const formatTokenStatus = (token, tokenType, now = Date.now(), instanceKey = 'default') => {
|
|
164
|
+
if (tokenType === 'device' || String(token || '').startsWith('cm_')) return 'device token · no expiry';
|
|
165
|
+
const expiresAt = decodeJwtExpiry(token);
|
|
166
|
+
if (!expiresAt) return 'session token · expiry unknown';
|
|
167
|
+
const diff = expiresAt - now;
|
|
168
|
+
if (diff <= 0) return `expired — commonly login --instance ${instanceKey}`;
|
|
169
|
+
const hours = Math.max(1, Math.ceil(diff / (60 * 60 * 1000)));
|
|
170
|
+
return `expires in ${hours}h`;
|
|
171
|
+
};
|
package/src/lib/api.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* unless overridden. This is the only place that makes HTTP calls.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { resolveInstanceUrl, getToken } from './config.js';
|
|
8
|
+
import { resolveInstanceUrl, getToken, resolveInstance } from './config.js';
|
|
9
9
|
|
|
10
10
|
const headers = (token, extra = {}) => ({
|
|
11
11
|
'Content-Type': 'application/json',
|
|
@@ -13,12 +13,21 @@ const headers = (token, extra = {}) => ({
|
|
|
13
13
|
...extra,
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
const
|
|
16
|
+
const knownSessionFailure = (body) => [body?.msg, body?.error, body?.message]
|
|
17
|
+
.some((value) => ['Token is not valid', 'Invalid API token', 'Account no longer exists'].includes(value));
|
|
18
|
+
|
|
19
|
+
export const sessionExpiredMessage = ({ instanceKey, baseUrl }) => (
|
|
20
|
+
`Session for ${instanceKey} (${baseUrl}) has expired.\nRun: commonly login --instance ${instanceKey}`
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const handleResponse = async (res, session = null) => {
|
|
17
24
|
const text = await res.text();
|
|
18
25
|
let body;
|
|
19
26
|
try { body = JSON.parse(text); } catch { body = { message: text }; }
|
|
20
27
|
if (!res.ok) {
|
|
21
|
-
const msg =
|
|
28
|
+
const msg = res.status === 401 && session && knownSessionFailure(body)
|
|
29
|
+
? sessionExpiredMessage(session)
|
|
30
|
+
: body?.error || body?.message || body?.msg || `HTTP ${res.status}`;
|
|
22
31
|
const err = new Error(msg);
|
|
23
32
|
err.status = res.status;
|
|
24
33
|
err.body = body;
|
|
@@ -27,32 +36,38 @@ const handleResponse = async (res) => {
|
|
|
27
36
|
return body;
|
|
28
37
|
};
|
|
29
38
|
|
|
30
|
-
export const createClient = ({ instance = null, token =
|
|
39
|
+
export const createClient = ({ instance = null, token = undefined } = {}) => {
|
|
31
40
|
const baseUrl = resolveInstanceUrl(instance);
|
|
32
|
-
const
|
|
41
|
+
const resolved = resolveInstance(instance);
|
|
42
|
+
const authToken = token === undefined ? getToken(instance) : token;
|
|
43
|
+
const session = {
|
|
44
|
+
instanceKey: resolved?.key || (typeof instance === 'string' && instance && !/^https?:\/\//i.test(instance) ? instance : 'default'),
|
|
45
|
+
baseUrl,
|
|
46
|
+
};
|
|
33
47
|
|
|
34
48
|
const get = (path, params = {}) => {
|
|
35
49
|
const url = new URL(`${baseUrl}${path}`);
|
|
36
50
|
Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
|
|
37
|
-
return fetch(url.toString(), { headers: headers(authToken) }).then(handleResponse);
|
|
51
|
+
return fetch(url.toString(), { headers: headers(authToken) }).then((res) => handleResponse(res, session));
|
|
38
52
|
};
|
|
39
53
|
|
|
40
54
|
const post = (path, body = {}) => fetch(`${baseUrl}${path}`, {
|
|
41
55
|
method: 'POST',
|
|
42
56
|
headers: headers(authToken),
|
|
43
57
|
body: JSON.stringify(body),
|
|
44
|
-
}).then(handleResponse);
|
|
58
|
+
}).then((res) => handleResponse(res, session));
|
|
45
59
|
|
|
46
60
|
const patch = (path, body = {}) => fetch(`${baseUrl}${path}`, {
|
|
47
61
|
method: 'PATCH',
|
|
48
62
|
headers: headers(authToken),
|
|
49
63
|
body: JSON.stringify(body),
|
|
50
|
-
}).then(handleResponse);
|
|
64
|
+
}).then((res) => handleResponse(res, session));
|
|
51
65
|
|
|
52
|
-
const del = (path) => fetch(`${baseUrl}${path}`, {
|
|
66
|
+
const del = (path, body) => fetch(`${baseUrl}${path}`, {
|
|
53
67
|
method: 'DELETE',
|
|
54
68
|
headers: headers(authToken),
|
|
55
|
-
|
|
69
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
70
|
+
}).then((res) => handleResponse(res, session));
|
|
56
71
|
|
|
57
72
|
// Multipart upload via native FormData/Blob (Node 18+) — no runtime deps.
|
|
58
73
|
// Content-Type is deliberately NOT set: fetch writes the multipart boundary.
|
|
@@ -72,7 +87,7 @@ export const createClient = ({ instance = null, token = null } = {}) => {
|
|
|
72
87
|
method: 'POST',
|
|
73
88
|
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
|
74
89
|
body: form,
|
|
75
|
-
}).then(handleResponse);
|
|
90
|
+
}).then((res) => handleResponse(res, session));
|
|
76
91
|
};
|
|
77
92
|
|
|
78
93
|
return {
|
package/src/lib/config.js
CHANGED
|
@@ -107,13 +107,16 @@ export const resolveInstanceUrl = (instanceArg = null) => {
|
|
|
107
107
|
return DEFAULT_INSTANCE_URL;
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
-
export const saveInstance = ({ key = 'default', url, token, userId, username }) => {
|
|
110
|
+
export const saveInstance = ({ key = 'default', url, token, userId, username, tokenType = null }) => {
|
|
111
111
|
const config = read();
|
|
112
112
|
config.instances[key] = {
|
|
113
113
|
url: url.replace(/\/$/, ''),
|
|
114
114
|
token,
|
|
115
115
|
userId,
|
|
116
116
|
username,
|
|
117
|
+
// Device bearers are long-lived until revoked. Preserve the explicit kind
|
|
118
|
+
// so `whoami` can say that without guessing from a future token format.
|
|
119
|
+
...(tokenType ? { tokenType } : {}),
|
|
117
120
|
savedAt: new Date().toISOString(),
|
|
118
121
|
};
|
|
119
122
|
config.active = key;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 8628-shaped device-code interaction for `commonly login`.
|
|
3
|
+
*
|
|
4
|
+
* The browser owns password/OAuth; the terminal only ever receives the
|
|
5
|
+
* resulting per-device bearer once from /device/poll. This module keeps the
|
|
6
|
+
* timing and terminal-key behaviour testable without a live TTY.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile as nodeExecFile } from 'child_process';
|
|
9
|
+
import { platform } from 'os';
|
|
10
|
+
import { emitKeypressEvents } from 'readline';
|
|
11
|
+
|
|
12
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
|
|
14
|
+
export class DeviceLoginCancelledError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('Login cancelled.');
|
|
17
|
+
this.name = 'DeviceLoginCancelledError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class DeviceLoginDeniedError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super('Denied in the browser. Nothing was saved.');
|
|
24
|
+
this.name = 'DeviceLoginDeniedError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DeviceLoginExpiredError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super('Device authorization code expired.');
|
|
31
|
+
this.name = 'DeviceLoginExpiredError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const safeBrowserUrl = (value) => {
|
|
36
|
+
const url = new URL(value);
|
|
37
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
38
|
+
throw new Error('Device authorization URL must use HTTP or HTTPS.');
|
|
39
|
+
}
|
|
40
|
+
return url.toString();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const openBrowser = (url, execFile = nodeExecFile, currentPlatform = platform()) => {
|
|
44
|
+
const browserUrl = safeBrowserUrl(url);
|
|
45
|
+
// `cmd /c start <url>` routes a server-supplied URL through a shell. Use a
|
|
46
|
+
// direct executable on Windows just as we do on macOS/Linux, so `o` cannot
|
|
47
|
+
// turn a malicious verifyUrl into a second command.
|
|
48
|
+
const command = currentPlatform === 'darwin' ? 'open' : currentPlatform === 'win32' ? 'rundll32' : 'xdg-open';
|
|
49
|
+
const args = currentPlatform === 'win32'
|
|
50
|
+
? ['url.dll,FileProtocolHandler', browserUrl]
|
|
51
|
+
: [browserUrl];
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
execFile(command, args, () => resolve());
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const waitForDeviceAuthorization = async ({
|
|
58
|
+
client,
|
|
59
|
+
deviceCode,
|
|
60
|
+
userCode,
|
|
61
|
+
verifyUrl,
|
|
62
|
+
interval = 5,
|
|
63
|
+
expiresIn = 600,
|
|
64
|
+
stdin = process.stdin,
|
|
65
|
+
wait = sleep,
|
|
66
|
+
onOpen = openBrowser,
|
|
67
|
+
onStatus = () => undefined,
|
|
68
|
+
now = () => Date.now(),
|
|
69
|
+
}) => {
|
|
70
|
+
const authorizeUrl = `${verifyUrl}?code=${encodeURIComponent(userCode)}`;
|
|
71
|
+
let cancelled = false;
|
|
72
|
+
let signalCancellation = () => {};
|
|
73
|
+
const cancellation = new Promise((resolve) => {
|
|
74
|
+
signalCancellation = resolve;
|
|
75
|
+
});
|
|
76
|
+
let currentInterval = Math.max(Number(interval) || 5, 1);
|
|
77
|
+
const deadline = now() + Math.max(Number(expiresIn) || 600, 1) * 1000;
|
|
78
|
+
const isTty = Boolean(stdin?.isTTY && typeof stdin.setRawMode === 'function');
|
|
79
|
+
|
|
80
|
+
const onKeypress = (value, key = {}) => {
|
|
81
|
+
if (key?.ctrl && key.name === 'c') {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
signalCancellation();
|
|
84
|
+
}
|
|
85
|
+
if (value === 'q') {
|
|
86
|
+
cancelled = true;
|
|
87
|
+
signalCancellation();
|
|
88
|
+
}
|
|
89
|
+
if (value === 'o') void onOpen(authorizeUrl);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (stdin?.on) {
|
|
93
|
+
emitKeypressEvents(stdin);
|
|
94
|
+
if (isTty) stdin.setRawMode(true);
|
|
95
|
+
stdin.on('keypress', onKeypress);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
while (now() < deadline) {
|
|
100
|
+
if (cancelled) throw new DeviceLoginCancelledError();
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
result = await client.post('/api/auth/device/poll', { deviceCode });
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error('Unable to complete device authorization. Try again.');
|
|
106
|
+
}
|
|
107
|
+
if (result?.status === 'authorized' && result.token) return result;
|
|
108
|
+
if (result?.status === 'denied') throw new DeviceLoginDeniedError();
|
|
109
|
+
if (result?.status === 'expired') throw new DeviceLoginExpiredError();
|
|
110
|
+
if (result?.status === 'slow_down') {
|
|
111
|
+
currentInterval *= 2;
|
|
112
|
+
onStatus('Waiting for browser approval (slowing down)…');
|
|
113
|
+
} else if (result?.status !== 'authorization_pending') {
|
|
114
|
+
throw new DeviceLoginExpiredError();
|
|
115
|
+
}
|
|
116
|
+
await Promise.race([
|
|
117
|
+
wait(currentInterval * 1000),
|
|
118
|
+
cancellation,
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
throw new DeviceLoginExpiredError();
|
|
122
|
+
} finally {
|
|
123
|
+
if (stdin?.removeListener) stdin.removeListener('keypress', onKeypress);
|
|
124
|
+
if (isTty) stdin.setRawMode(false);
|
|
125
|
+
}
|
|
126
|
+
};
|
package/src/lib/enforcement.js
CHANGED
|
@@ -420,11 +420,15 @@ export const createClaimKeeper = (client, {
|
|
|
420
420
|
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
421
421
|
},
|
|
422
422
|
|
|
423
|
-
async release() {
|
|
423
|
+
async release(outcome) {
|
|
424
424
|
stopRenewal();
|
|
425
425
|
if (!acquired || lost) return;
|
|
426
426
|
try {
|
|
427
|
-
|
|
427
|
+
// Preserve the one-argument legacy call when there is no explicit
|
|
428
|
+
// outcome. Besides keeping old clients' DELETE shape intact, callers
|
|
429
|
+
// that assert their transport arguments must not see a synthetic
|
|
430
|
+
// `undefined` body. An explicit outcome is the new D6.1 contract.
|
|
431
|
+
await (outcome ? client.del(path, { outcome }) : client.del(path));
|
|
428
432
|
} catch {
|
|
429
433
|
// Best-effort: a miss just means the lease already expired.
|
|
430
434
|
}
|