cofluxd 2.15.0 → 2.16.1
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 +27 -2
- package/account-client.mjs +69 -45
- package/coflux.mjs +226 -150
- package/cofluxd.mjs +885 -427
- package/output.mjs +93 -0
- package/package.json +3 -2
- package/release-trust.mjs +44 -36
- package/service-unit.mjs +52 -13
- package/skills/coflux/SKILL.md +6 -4
package/README.md
CHANGED
|
@@ -50,6 +50,31 @@ cofluxd up --key <join key>
|
|
|
50
50
|
|
|
51
51
|
Get the command, join key included, from **Add device → Headless** in the Coflux desktop app. The key is single use and valid for one hour; the host joins your account as soon as the command runs. Without `--key`, `cofluxd up` prints an authorization link instead: open it in a browser signed in to your account. Use `cofluxd status`, `cofluxd doctor`, or `cofluxd logs -f` to inspect it.
|
|
52
52
|
|
|
53
|
+
`cofluxd up` picks how the service runs:
|
|
54
|
+
|
|
55
|
+
- **macOS**: a launchd agent.
|
|
56
|
+
- **Linux with systemd**: systemd user units. If `systemctl --user` does not work in your session, `up` stops with an error instead of reporting success.
|
|
57
|
+
- **Linux without systemd** (a container, WSL without systemd, Alpine/OpenRC): cofluxd runs the service itself. `cofluxd status` shows it as `running (self-managed, no autostart)`. If the service process crashes it is restarted with terminals kept. `status`, `logs`, `restart`, `down` and `uninstall` all work, but nothing starts it again after a reboot: run `cofluxd up` again, or use `cofluxd run` under your own supervisor.
|
|
58
|
+
|
|
59
|
+
## Containers and other supervisors: `cofluxd run`
|
|
60
|
+
|
|
61
|
+
`cofluxd run` takes the same flags as `up`, downloads the binaries if needed, and runs the service in the foreground with its log on stdout and stderr. An unregistered device prints its authorization link in that output. `run` exits non-zero when its terminals' host stops, so the outer supervisor (a Docker restart policy, s6, supervisord, tmux) can start it again; SIGTERM or Ctrl-C stops everything and exits 0. A rejected join key is reported and `run` keeps running, so a restart policy never loops on a spent key. `run` refuses to start while another Coflux service runs for the same `~/.coflux`; stop that one with `cofluxd down` first.
|
|
62
|
+
|
|
63
|
+
```dockerfile
|
|
64
|
+
FROM node:22
|
|
65
|
+
RUN npm install -g cofluxd
|
|
66
|
+
VOLUME /root/.coflux
|
|
67
|
+
ENTRYPOINT ["cofluxd", "run"]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
docker build -t coflux-device .
|
|
72
|
+
docker run -d --init --restart unless-stopped --name coflux-device \
|
|
73
|
+
-v coflux-home:/root/.coflux coflux-device --key <join key>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use `--init` (or `init: true` in Compose): Node does not reap orphaned processes when it is PID 1. Keep `~/.coflux` on a volume so the device keeps its identity and binaries across restarts; once the device has joined, the key is ignored. `docker stop` ends the terminals and exits cleanly.
|
|
77
|
+
|
|
53
78
|
## Operate your workspaces
|
|
54
79
|
|
|
55
80
|
```sh
|
|
@@ -82,9 +107,9 @@ The CLI bundled with the desktop app can reuse the app's login through a local c
|
|
|
82
107
|
|
|
83
108
|
## Upgrades and terminal lifetime
|
|
84
109
|
|
|
85
|
-
Updating this npm package does not end running terminals. `cofluxd update` downloads
|
|
110
|
+
Updating this npm package does not end running terminals. `cofluxd update` downloads the new version without restarting anything; it tells you when `cofluxd restart` is needed to apply it.
|
|
86
111
|
|
|
87
|
-
`cofluxd restart` and `cofluxd down` end local terminal processes. The desktop app stays online in the background; fully quitting or signing out ends its local terminals after confirmation.
|
|
112
|
+
`cofluxd restart` keeps terminals open. `cofluxd restart --ptyd` and `cofluxd down` end local terminal processes. The desktop app stays online in the background; fully quitting or signing out ends its local terminals after confirmation.
|
|
88
113
|
|
|
89
114
|
Starting with 1.0, operation commands such as `cofluxd terminal` and `cofluxd login` have been removed. Use `coflux terminal` and `coflux login`. The MCP interface has also been removed in favor of the CLI.
|
|
90
115
|
|
package/account-client.mjs
CHANGED
|
@@ -7,13 +7,23 @@ import net from "node:net";
|
|
|
7
7
|
import { hostname } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import readline from "node:readline";
|
|
10
|
+
import { error as printError, success } from "./output.mjs";
|
|
11
|
+
|
|
12
|
+
/** An error with the next step to show under it. */
|
|
13
|
+
function cliError(message, next) {
|
|
14
|
+
const error = new Error(message);
|
|
15
|
+
if (next) error.next = next;
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
const USAGE_NEXT = "Run coflux --help for usage.";
|
|
19
|
+
const LOGIN_NEXT = "Sign in to the Coflux app, or run coflux login.";
|
|
10
20
|
|
|
11
21
|
/* --------------------------------- 实体标识 -------------------------------- */
|
|
12
22
|
// `coflux:<kind>:<hex>`:设备 / 项目 / 工作区 / 终端 ID 的可粘贴短形式,hex 是 ID 的前几位
|
|
13
23
|
// (生成时固定取前 8 位)。解析大小写不敏感并归一成小写;凡是收 ID 的地方都收标识。
|
|
14
24
|
//
|
|
15
25
|
// 规则是纯拼接,故各端各自本地生成,不上协议:Rust 侧同一份规则在 crates/cli/src/handle.rs 与
|
|
16
|
-
// crates/
|
|
26
|
+
// crates/runtime/src/handle.rs——两版 CLI 的输出是逐字对齐的契约,改一边必须改另一边。
|
|
17
27
|
const HANDLE_KINDS = ["device", "project", "workspace", "terminal"];
|
|
18
28
|
|
|
19
29
|
/** `id` 的标识。空进空出:缺坐标时不能造出 `coflux:x:` 这样的半截标识。 */
|
|
@@ -44,22 +54,26 @@ export function matchesTarget(target, id, kind) {
|
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
/** 筛选参数拿到了别的类型的标识:说清楚,不要打印空列表。不是标识的一律放行(那就是个 ID)。 */
|
|
47
|
-
const HANDLE_LABELS = { device: "设备", project: "项目", workspace: "工作区", terminal: "终端" };
|
|
48
57
|
export function checkFilterHandle(flag, expected, target) {
|
|
49
58
|
const handle = parseHandle(target);
|
|
50
59
|
if (handle && handle.kind !== expected) {
|
|
51
|
-
throw
|
|
60
|
+
throw cliError(
|
|
61
|
+
`--${flag} needs a ${expected} id or handle, but ${target} is a ${handle.kind} handle.`,
|
|
62
|
+
`Run coflux ${expected === "workspace" ? "workspace" : "device"} list to find the ${expected} id.`,
|
|
63
|
+
);
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
function origin(raw) {
|
|
56
68
|
const url = new URL(raw.replace(/^wss:/, "https:").replace(/^ws:/, "http:"));
|
|
57
|
-
if (url.username || url.password) throw
|
|
58
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
69
|
+
if (url.username || url.password) throw cliError("The server URL must not contain credentials.", "Pass it as --server https://<host>.");
|
|
70
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
|
|
71
|
+
throw cliError("The server URL must use HTTPS.", "Use https://, or http://localhost for local development.");
|
|
72
|
+
}
|
|
59
73
|
return url.origin;
|
|
60
74
|
}
|
|
61
75
|
function unwrap(body) {
|
|
62
|
-
if (body?.ok !== true) throw new Error(body?.error || "
|
|
76
|
+
if (body?.ok !== true) throw new Error(body?.error || "The account request failed.");
|
|
63
77
|
return body.value;
|
|
64
78
|
}
|
|
65
79
|
async function request(server, path, token, body, timeout) {
|
|
@@ -71,10 +85,10 @@ function broker(home, body, timeout) {
|
|
|
71
85
|
const socket = net.createConnection(join(home, "client.sock"));
|
|
72
86
|
let text = "";
|
|
73
87
|
socket.setEncoding("utf8");
|
|
74
|
-
socket.setTimeout(timeout, () => socket.destroy(
|
|
75
|
-
socket.on("error", (error) => reject(error.code === "ENOENT" || error.code === "ECONNREFUSED" ?
|
|
88
|
+
socket.setTimeout(timeout, () => socket.destroy(cliError("The account request timed out.", "Check whether it took effect before you try again.")));
|
|
89
|
+
socket.on("error", (error) => reject(error.code === "ENOENT" || error.code === "ECONNREFUSED" ? cliError("You are not signed in.", LOGIN_NEXT) : error));
|
|
76
90
|
socket.on("connect", () => socket.write(JSON.stringify(body) + "\n"));
|
|
77
|
-
socket.on("data", (chunk) => { text += chunk; if (Buffer.byteLength(text) > 8 * 1024 * 1024) socket.destroy(new Error("
|
|
91
|
+
socket.on("data", (chunk) => { text += chunk; if (Buffer.byteLength(text) > 8 * 1024 * 1024) socket.destroy(new Error("The response was too large.")); });
|
|
78
92
|
socket.on("end", () => { try { resolve(unwrap(JSON.parse(text))); } catch (error) { reject(error); } });
|
|
79
93
|
});
|
|
80
94
|
}
|
|
@@ -83,7 +97,11 @@ function broker(home, body, timeout) {
|
|
|
83
97
|
// redirect with PKCE (S256), or a paste code when the browser cannot reach back (SSH, or forced with
|
|
84
98
|
// COFLUX_LOGIN_PASTE=1). Same requests, same copy as the Rust CLI (crates/cli/src/browser_login.rs).
|
|
85
99
|
|
|
86
|
-
const LOGIN_FAILURES = {
|
|
100
|
+
const LOGIN_FAILURES = {
|
|
101
|
+
not_allowed: "This email address has no Coflux access.",
|
|
102
|
+
not_verified: "This account's email address is not verified.",
|
|
103
|
+
cancelled: "Sign-in was cancelled.",
|
|
104
|
+
};
|
|
87
105
|
|
|
88
106
|
function prefersPaste() {
|
|
89
107
|
return ["COFLUX_LOGIN_PASTE", "SSH_CONNECTION", "SSH_TTY", "SSH_CLIENT"].some((name) => !!process.env[name]);
|
|
@@ -104,7 +122,7 @@ function escapeHtml(value) {
|
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
function callbackPage(title, body) {
|
|
107
|
-
return `<!doctype html><html lang="
|
|
125
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)} - Coflux</title><style>:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font:15px/1.5 -apple-system,BlinkMacSystemFont,sans-serif}main{text-align:center;padding:24px}h1{font-size:18px}</style></head><body><main><h1>${escapeHtml(title)}</h1><p>${escapeHtml(body)}</p></main></body></html>`;
|
|
108
126
|
}
|
|
109
127
|
|
|
110
128
|
function listenLoopback() {
|
|
@@ -141,19 +159,19 @@ async function browserLogin(server) {
|
|
|
141
159
|
}
|
|
142
160
|
const page = new URL(registered.url);
|
|
143
161
|
// Only ever open a page on the server we are logging into.
|
|
144
|
-
if (page.origin !== server) { listener?.close(); throw
|
|
162
|
+
if (page.origin !== server) { listener?.close(); throw cliError("The server returned a sign-in page on another host.", "Check the --server URL, then run coflux login again."); }
|
|
145
163
|
const exchange = (code) => request(server, "/api/client/login/exchange", null, { protocolVersion: 1, code, codeVerifier: verifier }, 30000);
|
|
146
|
-
process.stderr.write(
|
|
164
|
+
process.stderr.write(`To sign in, open this page in your browser:\n\n ${page.href}\n\nPress Ctrl-C to cancel.\n`);
|
|
147
165
|
if (!listener) {
|
|
148
|
-
process.stderr.write("
|
|
149
|
-
const code = await askLine("
|
|
150
|
-
if (!code) throw
|
|
166
|
+
process.stderr.write("After you sign in, the page shows a one-time code.\n");
|
|
167
|
+
const code = await askLine("Paste the code: ");
|
|
168
|
+
if (!code) throw cliError("No code entered.", "Run coflux login again.");
|
|
151
169
|
return exchange(code);
|
|
152
170
|
}
|
|
153
171
|
openBrowser(page.href);
|
|
154
172
|
const waitMs = typeof registered.expiresAt === "number" && registered.expiresAt > Date.now() ? registered.expiresAt - Date.now() : 600000;
|
|
155
173
|
return new Promise((resolve, reject) => {
|
|
156
|
-
const timer = setTimeout(() => finish(
|
|
174
|
+
const timer = setTimeout(() => finish(cliError("Sign-in timed out.", "Run coflux login again.")), waitMs);
|
|
157
175
|
let done = false;
|
|
158
176
|
function finish(error, value) {
|
|
159
177
|
if (done) return;
|
|
@@ -166,17 +184,17 @@ async function browserLogin(server) {
|
|
|
166
184
|
listener.on("request", (req, res) => {
|
|
167
185
|
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
168
186
|
const answer = (status, title, body) => { res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); res.end(callbackPage(title, body)); };
|
|
169
|
-
if (req.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state) { answer(404, "
|
|
187
|
+
if (req.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state) { answer(404, "Not found", "This address only serves the Coflux sign-in callback."); return; }
|
|
170
188
|
const failed = url.searchParams.get("error");
|
|
171
189
|
if (failed) {
|
|
172
|
-
const message = LOGIN_FAILURES[failed] ?? "
|
|
173
|
-
answer(200, "
|
|
174
|
-
finish(
|
|
190
|
+
const message = LOGIN_FAILURES[failed] ?? "Sign-in did not complete.";
|
|
191
|
+
answer(200, "Sign-in not completed", `${message} You can close this page and return to the terminal.`);
|
|
192
|
+
finish(cliError(message, "Run coflux login again."));
|
|
175
193
|
return;
|
|
176
194
|
}
|
|
177
195
|
exchange(url.searchParams.get("code") ?? "").then(
|
|
178
|
-
(value) => { answer(200, "
|
|
179
|
-
(error) => { answer(200, "
|
|
196
|
+
(value) => { answer(200, "Signed in", "You are signed in. You can return to the terminal."); finish(null, value); },
|
|
197
|
+
(error) => { answer(200, "Sign-in not completed", "Sign-in did not complete. See the terminal for the reason."); finish(error); },
|
|
180
198
|
);
|
|
181
199
|
});
|
|
182
200
|
});
|
|
@@ -204,22 +222,24 @@ export function handlesAccountCommand(positionals, flags, home) {
|
|
|
204
222
|
export async function runAccountCommand(positionals, flags, home) {
|
|
205
223
|
const [command, sub = "list", id] = positionals;
|
|
206
224
|
const sessionPath = join(home, "cli-session.json");
|
|
207
|
-
const required = (key) => { if (!flags[key]) throw
|
|
208
|
-
const target = () => { if (!id) throw
|
|
225
|
+
const required = (key) => { if (!flags[key]) throw cliError(`Missing --${key}.`, USAGE_NEXT); return flags[key]; };
|
|
226
|
+
const target = () => { if (!id) throw cliError("Missing id.", USAGE_NEXT); return id; };
|
|
209
227
|
// `project import <path>`: the path is resolved on the **target device** (`~` expansion and
|
|
210
228
|
// `git rev-parse --show-toplevel` both happen there), so the CLI only checks its shape — the same
|
|
211
229
|
// rule as `device exec --cwd`. Expanding it here would resolve the caller's home on the wrong machine.
|
|
212
230
|
const importPath = () => {
|
|
213
231
|
const value = (id ?? "").trim();
|
|
214
|
-
if (!value) throw
|
|
215
|
-
if (!(value.startsWith("/") || value === "~" || value.startsWith("~/")))
|
|
232
|
+
if (!value) throw cliError("Missing path.", 'To import the current directory, run coflux project import "$PWD".');
|
|
233
|
+
if (!(value.startsWith("/") || value === "~" || value.startsWith("~/"))) {
|
|
234
|
+
throw cliError("The path must be absolute or start with ~, because it is resolved on the device.", 'To import the current directory, run coflux project import "$PWD".');
|
|
235
|
+
}
|
|
216
236
|
return value;
|
|
217
237
|
};
|
|
218
238
|
// `--device` falls back to the daemon-issued COFLUX_DEVICE_ID; empty on both sides is an error,
|
|
219
239
|
// never a guess — silently importing onto the wrong machine is worse than failing.
|
|
220
240
|
const deviceTarget = () => {
|
|
221
241
|
const value = (flags.device || process.env.COFLUX_DEVICE_ID || "").trim();
|
|
222
|
-
if (!value) throw
|
|
242
|
+
if (!value) throw cliError("Missing device.", "Pass --device <id>. Run coflux device list to see your devices.");
|
|
223
243
|
return value;
|
|
224
244
|
};
|
|
225
245
|
const print = (value) => console.log(JSON.stringify(value));
|
|
@@ -228,15 +248,15 @@ export async function runAccountCommand(positionals, flags, home) {
|
|
|
228
248
|
// No credential flags: sign in through the browser (loopback + PKCE, or a paste code over SSH).
|
|
229
249
|
if (!flags.username && !flags["password-stdin"]) {
|
|
230
250
|
const value = await browserLogin(server);
|
|
231
|
-
if (!value?.token) throw
|
|
251
|
+
if (!value?.token) throw cliError("The server did not return a session.", "Run coflux login again.");
|
|
232
252
|
saveSession(home, sessionPath, { server, token: value.token, accountId: value.accountId });
|
|
233
|
-
|
|
253
|
+
success(value.login ? `Signed in as ${value.login}` : "Signed in");
|
|
234
254
|
return;
|
|
235
255
|
}
|
|
236
256
|
const username = required("username");
|
|
237
|
-
if (!flags["password-stdin"]) throw
|
|
257
|
+
if (!flags["password-stdin"]) throw cliError("Missing --password-stdin.", "Pipe the password on stdin and pass --password-stdin.");
|
|
238
258
|
let password = "";
|
|
239
|
-
for await (const chunk of process.stdin) { password += chunk; if (Buffer.byteLength(password) > 4096) throw
|
|
259
|
+
for await (const chunk of process.stdin) { password += chunk; if (Buffer.byteLength(password) > 4096) throw cliError("The password is too long.", "Check what is piped to stdin."); if (password.includes("\n")) break; }
|
|
240
260
|
const value = await request(server, "/api/client/login", null, { protocolVersion: 1, username, password: password.split("\n", 1)[0].replace(/\r$/, "") }, 30000);
|
|
241
261
|
saveSession(home, sessionPath, { server, token: value.token, accountId: value.accountId });
|
|
242
262
|
print({ accountId: value.accountId, server });
|
|
@@ -248,16 +268,16 @@ export async function runAccountCommand(positionals, flags, home) {
|
|
|
248
268
|
// `terminal.wait` 与 `device.exec` 都可能在中心侧阻塞到 600 秒;其余账号操作 40 秒足够。
|
|
249
269
|
const timeout = operation.op === "terminal.wait" || operation.op === "device.exec" ? 610000 : 40000;
|
|
250
270
|
if (!session) {
|
|
251
|
-
if (flags.server) throw
|
|
271
|
+
if (flags.server) throw cliError("You are not signed in to that server.", "Run coflux login --server <url> first.");
|
|
252
272
|
return broker(home, body, timeout);
|
|
253
273
|
}
|
|
254
274
|
const server = origin(session.server);
|
|
255
|
-
if (flags.server && origin(flags.server) !== server) throw
|
|
256
|
-
if (!session.token) throw
|
|
275
|
+
if (flags.server && origin(flags.server) !== server) throw cliError("You are signed in to a different server.", "Run coflux login --server <url> first.");
|
|
276
|
+
if (!session.token) throw cliError("You are not signed in.", "Run coflux login.");
|
|
257
277
|
return request(server, "/api/client/command", session.token, body, timeout);
|
|
258
278
|
};
|
|
259
279
|
if (command === "logout") {
|
|
260
|
-
if (!session) throw
|
|
280
|
+
if (!session) throw cliError("This CLI is not signed in on its own.", "To sign out of the app account, sign out in Coflux.");
|
|
261
281
|
await call({ op: "logout" });
|
|
262
282
|
fs.rmSync(sessionPath);
|
|
263
283
|
print({ loggedOut: true });
|
|
@@ -269,21 +289,23 @@ export async function runAccountCommand(positionals, flags, home) {
|
|
|
269
289
|
// (device offline, capability missing, bad cwd, timeout, bad arguments) all exit 255, so a caller's
|
|
270
290
|
// shell test can tell "the remote command returned 1" from "it never ran".
|
|
271
291
|
if (command === "device" && sub === "exec") {
|
|
272
|
-
const fail =
|
|
292
|
+
const fail = (message, next = USAGE_NEXT) => { printError(message, next); process.exit(255); };
|
|
273
293
|
let value;
|
|
274
294
|
try {
|
|
275
|
-
if (!id) throw
|
|
295
|
+
if (!id) throw cliError("Missing device id.", "Run coflux device list to see your devices.");
|
|
276
296
|
const cmd = required("cmd");
|
|
277
297
|
const timeout = Number(flags.timeout ?? 60);
|
|
278
|
-
//
|
|
279
|
-
if (!Number.isInteger(timeout)) throw
|
|
280
|
-
if (timeout < 1 || timeout > 600)
|
|
298
|
+
// State the limits here rather than let the server's validation answer with a bare failure.
|
|
299
|
+
if (!Number.isInteger(timeout)) throw cliError("--timeout must be a whole number of seconds.", "Use a value from 1 to 600.");
|
|
300
|
+
if (timeout < 1 || timeout > 600) {
|
|
301
|
+
throw cliError("--timeout must be between 1 and 600 seconds.", "For longer work the user should see, open a terminal with coflux terminal new.");
|
|
302
|
+
}
|
|
281
303
|
value = await call({ op: "device.exec", deviceId: id, command: cmd, cwd: flags.cwd ?? "", timeout });
|
|
282
304
|
} catch (error) {
|
|
283
|
-
|
|
305
|
+
fail(error.message, error.next);
|
|
284
306
|
}
|
|
285
307
|
const exitCode = Number(value?.exitCode);
|
|
286
|
-
if (!Number.isInteger(exitCode))
|
|
308
|
+
if (!Number.isInteger(exitCode)) fail("The device did not report an exit code.", "The server may need an update; try again later.");
|
|
287
309
|
await writeAll(process.stdout, String(value.stdout ?? ""));
|
|
288
310
|
await writeAll(process.stderr, String(value.stderr ?? ""));
|
|
289
311
|
await writeAll(process.stdout, `# exit=${exitCode}\n`);
|
|
@@ -307,9 +329,11 @@ export async function runAccountCommand(positionals, flags, home) {
|
|
|
307
329
|
if (sub === "wait") operation = { op: "terminal.wait", terminalId: target(), timeout: Number(flags.timeout ?? 30) };
|
|
308
330
|
if (["stop", "remove"].includes(sub)) operation = { op: `terminal.${sub}`, terminalId: target() };
|
|
309
331
|
}
|
|
310
|
-
if (!operation && command !== "whoami" && command !== "ports" && sub !== "list") throw
|
|
332
|
+
if (!operation && command !== "whoami" && command !== "ports" && sub !== "list") throw cliError(`Unknown command: ${positionals.join(" ")}`, "Run coflux --help to see the commands.");
|
|
311
333
|
// `project.import` is addressed by device like `snapshot`, so `--device` is an input to it rather than a filter.
|
|
312
|
-
if (operation && ((flags.device && operation.op !== "project.import") || (flags.workspace && operation.op !== "terminal.new")))
|
|
334
|
+
if (operation && ((flags.device && operation.op !== "project.import") || (flags.workspace && operation.op !== "terminal.new"))) {
|
|
335
|
+
throw cliError("--device and --workspace cannot be combined with an id.", "Drop the filter; the id already names the target.");
|
|
336
|
+
}
|
|
313
337
|
let value = await call(operation || { op: "snapshot" });
|
|
314
338
|
if (command === "whoami") value = { accountId: value.accountId };
|
|
315
339
|
else if (sub === "list" || command === "ports") {
|