cofluxd 2.0.2 → 2.1.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.
@@ -3,6 +3,50 @@ import fs from "node:fs";
3
3
  import net from "node:net";
4
4
  import { join } from "node:path";
5
5
 
6
+ /* --------------------------------- 实体标识 -------------------------------- */
7
+ // `coflux:<kind>:<hex>`:设备 / 项目 / 工作区 / 终端 ID 的可粘贴短形式,hex 是 ID 的前几位
8
+ // (生成时固定取前 8 位)。解析大小写不敏感并归一成小写;凡是收 ID 的地方都收标识。
9
+ //
10
+ // 规则是纯拼接,故各端各自本地生成,不上协议:Rust 侧同一份规则在 crates/cli/src/handle.rs 与
11
+ // crates/worker/src/handle.rs——两版 CLI 的输出是逐字对齐的契约,改一边必须改另一边。
12
+ const HANDLE_KINDS = ["device", "project", "workspace", "terminal"];
13
+
14
+ /** `id` 的标识。空进空出:缺坐标时不能造出 `coflux:x:` 这样的半截标识。 */
15
+ export function entityHandle(kind, id) {
16
+ const raw = typeof id === "string" ? id : "";
17
+ return raw ? `coflux:${kind}:${raw.slice(0, 8).toLowerCase()}` : "";
18
+ }
19
+
20
+ /** 解析标识;不是标识(裸 UUID 也一样)返回 null,调用方按它看起来的那个 ID 处理。 */
21
+ export function parseHandle(raw) {
22
+ if (typeof raw !== "string") return null;
23
+ const parts = raw.toLowerCase().split(":");
24
+ if (parts.length !== 3 || parts[0] !== "coflux") return null;
25
+ const [, kind, hex] = parts;
26
+ if (!HANDLE_KINDS.includes(kind) || !/^[0-9a-f]{4,32}$/.test(hex)) return null;
27
+ return { kind, prefix: hex };
28
+ }
29
+
30
+ /**
31
+ * `--device` / `--workspace` 筛选值命中某个 ID 吗?标识按类型 + 前缀比,其余按原样相等比。
32
+ * 这是**比较**不是解析:不查表,只用同一套语法——也正因为如此,标识绝不能漏到字符串相等那条
33
+ * 路上去,否则它谁也匹配不上,打印一个空列表还不报错。
34
+ */
35
+ export function matchesTarget(target, id, kind) {
36
+ const handle = parseHandle(target);
37
+ if (!handle) return id === target;
38
+ return handle.kind === kind && typeof id === "string" && id.toLowerCase().startsWith(handle.prefix);
39
+ }
40
+
41
+ /** 筛选参数拿到了别的类型的标识:说清楚,不要打印空列表。不是标识的一律放行(那就是个 ID)。 */
42
+ const HANDLE_LABELS = { device: "设备", project: "项目", workspace: "工作区", terminal: "终端" };
43
+ export function checkFilterHandle(flag, expected, target) {
44
+ const handle = parseHandle(target);
45
+ if (handle && handle.kind !== expected) {
46
+ throw new Error(`--${flag} 需要${HANDLE_LABELS[expected]}标识或${HANDLE_LABELS[expected]} ID,给的是${HANDLE_LABELS[handle.kind]}标识 ${target}`);
47
+ }
48
+ }
49
+
6
50
  function origin(raw) {
7
51
  const url = new URL(raw.replace(/^wss:/, "https:").replace(/^ws:/, "http:"));
8
52
  if (url.username || url.password) throw new Error("服务器地址不能包含凭据");
@@ -45,6 +89,22 @@ export async function runAccountCommand(positionals, flags, home) {
45
89
  const sessionPath = join(home, "cli-session.json");
46
90
  const required = (key) => { if (!flags[key]) throw new Error(`缺少 --${key}`); return flags[key]; };
47
91
  const target = () => { if (!id) throw new Error("缺少目标 ID"); return id; };
92
+ // `project import <path>`: the path is resolved on the **target device** (`~` expansion and
93
+ // `git rev-parse --show-toplevel` both happen there), so the CLI only checks its shape — the same
94
+ // rule as `device exec --cwd`. Expanding it here would resolve the caller's home on the wrong machine.
95
+ const importPath = () => {
96
+ const value = (id ?? "").trim();
97
+ if (!value) throw new Error('缺少要导入的路径(导入当前目录写 coflux project import "$PWD")');
98
+ if (!(value.startsWith("/") || value === "~" || value.startsWith("~/"))) throw new Error('路径要绝对路径或 ~ 开头(它在目标设备上解析);导入当前目录写 coflux project import "$PWD"');
99
+ return value;
100
+ };
101
+ // `--device` falls back to the daemon-issued COFLUX_DEVICE_ID; empty on both sides is an error,
102
+ // never a guess — silently importing onto the wrong machine is worse than failing.
103
+ const deviceTarget = () => {
104
+ const value = (flags.device || process.env.COFLUX_DEVICE_ID || "").trim();
105
+ if (!value) throw new Error("缺少设备:请加 --device <id>(coflux device list 可以看到)");
106
+ return value;
107
+ };
48
108
  const print = (value) => console.log(JSON.stringify(value));
49
109
  if (command === "login") {
50
110
  const server = origin(flags.server || "https://api.coflux.dev");
@@ -109,6 +169,9 @@ export async function runAccountCommand(positionals, flags, home) {
109
169
  process.exit(exitCode);
110
170
  }
111
171
  let operation;
172
+ if (command === "project") {
173
+ if (sub === "import") operation = { op: "project.import", daemonId: deviceTarget(), path: importPath(), ...(flags.name ? { name: flags.name } : {}) };
174
+ }
112
175
  if (command === "workspace") {
113
176
  if (sub === "new") operation = { op: "workspace.new", projectId: required("project"), branch: required("branch"), createNew: !flags["existing-branch"], ...(flags.name ? { name: flags.name } : {}) };
114
177
  if (sub === "rename") operation = { op: "workspace.rename", workspaceId: target(), name: required("name") };
@@ -124,12 +187,16 @@ export async function runAccountCommand(positionals, flags, home) {
124
187
  if (["stop", "remove"].includes(sub)) operation = { op: `terminal.${sub}`, terminalId: target() };
125
188
  }
126
189
  if (!operation && command !== "whoami" && command !== "ports" && sub !== "list") throw new Error("未知账号命令");
127
- if (operation && (flags.device || (flags.workspace && operation.op !== "terminal.new"))) throw new Error("目标 ID 已确定作用范围,请不要附加设备或工作区筛选参数");
190
+ // `project.import` is addressed by device like `snapshot`, so `--device` is an input to it rather than a filter.
191
+ if (operation && ((flags.device && operation.op !== "project.import") || (flags.workspace && operation.op !== "terminal.new"))) throw new Error("目标 ID 已确定作用范围,请不要附加设备或工作区筛选参数");
128
192
  let value = await call(operation || { op: "snapshot" });
129
193
  if (command === "whoami") value = { accountId: value.accountId };
130
194
  else if (sub === "list" || command === "ports") {
131
195
  const field = { device: "devices", project: "projects", workspace: "workspaces", terminal: "terminals", ports: "ports" }[command];
132
- value = value[field].filter((item) => (!flags.device || item.daemonId === flags.device) && (!flags.workspace || item.workspaceId === flags.workspace || (command === "workspace" && item.id === flags.workspace)));
196
+ // 客户端字符串比较,不经中心解析:标识不在这里认,就会一个都匹配不上(空列表、还不报错)。
197
+ if (flags.device) checkFilterHandle("device", "device", flags.device);
198
+ if (flags.workspace) checkFilterHandle("workspace", "workspace", flags.workspace);
199
+ value = value[field].filter((item) => (!flags.device || matchesTarget(flags.device, item.daemonId, "device")) && (!flags.workspace || matchesTarget(flags.workspace, item.workspaceId, "workspace") || (command === "workspace" && matchesTarget(flags.workspace, item.id, "workspace"))));
133
200
  }
134
201
  print(value);
135
202
  }
package/coflux.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // coflux:账号与本地、跨设备业务操作;不负责宿主生命周期。
3
- import { handlesAccountCommand, runAccountCommand } from "./account-client.mjs";
3
+ import { entityHandle, handlesAccountCommand, runAccountCommand } from "./account-client.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { parseArgs } from "node:util";
6
6
  import { spawnSync } from "node:child_process";
@@ -206,6 +206,15 @@ function tailLines(text, n) {
206
206
  return lines.slice(-n).join("\n");
207
207
  }
208
208
 
209
+ /**
210
+ * 一行终端的第一列:标识。daemon 已经在载荷里给了 `ref`;它没给(CLI 比 daemon 新)就按同一条
211
+ * 规则从 `taskId` 现算一个——生成规则是纯拼接,两边算出来的东西一样。
212
+ * 与 Rust 版 `row_handle`(crates/cli/src/commands.rs)逐字对齐。
213
+ */
214
+ function rowHandle(t) {
215
+ return (typeof t.ref === "string" && t.ref) || entityHandle("terminal", t.taskId);
216
+ }
217
+
209
218
  /** ` busy` / ` idle` plus ` last=<code>` for a live, instrumented terminal; nothing otherwise. */
210
219
  function commandSuffix(t) {
211
220
  if (!t.integrated) return "";
@@ -256,7 +265,7 @@ async function cmdTerminal(values) {
256
265
  if (!terminals.length) return void console.log("本工作区暂无终端");
257
266
  for (const t of terminals) {
258
267
  const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
259
- console.log(`${t.taskId} ${t.status}${exit}${commandSuffix(t)} ${t.title}`);
268
+ console.log(`${rowHandle(t)} ${t.status}${exit}${commandSuffix(t)} ${t.title}`);
260
269
  }
261
270
  } else if (sub === "read") {
262
271
  const taskId = positionals[2];
@@ -330,10 +339,14 @@ async function cmdWorkspace() {
330
339
  const sub = positionals[1];
331
340
  if (!sub) {
332
341
  const result = await agentPost({ action: "workspace.current" });
342
+ // ref / owningRef 原样透传:daemon 旧到不给就是 undefined,JSON.stringify 直接省掉这两个键
343
+ // (与 Rust 版 render_workspace_current 同序同省略规则)。
333
344
  return void console.log(JSON.stringify({
334
345
  workspaceId: result.workspaceId,
346
+ ref: result.ref,
335
347
  path: result.path,
336
348
  owningWorkspaceId: result.owningWorkspaceId,
349
+ owningRef: result.owningRef,
337
350
  moved: Boolean(result.moved),
338
351
  }));
339
352
  }
@@ -522,6 +535,11 @@ const HELP = `coflux —— 账号与终端操作
522
535
  该 worktree 已被删掉:其下所有终端搬回项目主工作区、工作区记录消失
523
536
  (不执行 git worktree remove)
524
537
 
538
+ 实体标识:设备 / 项目 / 工作区 / 终端的 ID 都可以写成 coflux:<kind>:<ID 前 8 位>,例如
539
+ coflux:workspace:3f2a1b7c。凡是收 ID 的地方都收标识(大小写不敏感),返回实体的地方都带一个
540
+ ref 字段给出它的标识。前缀在范围内撞车时会让你改用完整 ID;标识类型与命令要的不一致会直接报错,
541
+ 不会去动旁边那个实体。
542
+
525
543
  agent 命令的环境变量:COFLUX_AGENT_TIMEOUT_MS 收窄单次请求的等待上限(默认 30000,只能调小),
526
544
  供有硬超时的 hook 脚本用——到点干净失败,好过被宿主杀在半路。
527
545
 
@@ -537,6 +555,14 @@ agent 命令的环境变量:COFLUX_AGENT_TIMEOUT_MS 收窄单次请求的等
537
555
  任何工作区。--cwd 默认 daemon 用户的 HOME,只接受绝对路径或 ~ 开头的路径;
538
556
  --timeout 默认 60 秒、最长 600 秒;没有 stdin。要输密码、驱动 TUI,或想让
539
557
  用户看见过程并能接管的长任务,用 coflux terminal new,不要用它
558
+ coflux project import <path> [--device <id>] [--name <名称>]
559
+ 把设备上的一个 git 仓库目录变成项目(路径在仓库里就导入仓库根),并
560
+ 建好它的主工作区;打印一行 JSON:projectId / name / repoPath /
561
+ defaultBranch / workspaceId / path / alreadyImported。<path> 必填,
562
+ 只接受绝对路径或 ~ 开头的路径(它在目标设备上解析)——导入当前目录写
563
+ coflux project import "$PWD"。--device 缺省取 COFLUX_DEVICE_ID。
564
+ 同一个仓库根导入第二次不会多出一个项目:返回已有的那个,
565
+ alreadyImported=true
540
566
  coflux workspace new --project <id> --branch <分支> [--existing-branch]
541
567
  coflux workspace rename <id> --name <名称> | workspace remove <id>
542
568
  coflux terminal new --workspace <id> [--cmd <命令>]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "2.0.2",
3
+ "version": "2.1.1",
4
4
  "description": "Coflux 无界面宿主(cofluxd)与统一操作工具(coflux)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,6 +59,11 @@ env | grep '^COFLUX_'
59
59
  | `COFLUX_TASK_ID` | id of this terminal (the taskId / terminalId used by local commands and `read_terminal`) |
60
60
  | `COFLUX_SESSION_ID` | id of this PTY session |
61
61
 
62
+ Under those id lines the `<coflux-session>` block prints a `<Kind> handle:` line for the device,
63
+ project, workspace and terminal — the same ids in a short, typed, pasteable form (see "Entity
64
+ handles"). A PTY session has no handle, a coordinate that is empty contributes no line, and the
65
+ `COFLUX_*` variables themselves are always full UUIDs.
66
+
62
67
  - **Variables empty or absent**: there is no local terminal context. Use `coflux whoami` to check
63
68
  account access and the account CLI to discover workspaces. Ask the user to log in when needed;
64
69
  never fabricate COFLUX_* coordinates.
@@ -138,9 +143,9 @@ So, after entering or leaving a worktree, owning **and** effective are both the
138
143
  its id to account CLI commands and everything local already acts on it. The plugin drops the new id next to the
139
144
  tool result, and `coflux workspace` always tells you. Two things stay behind on purpose:
140
145
 
141
- - `COFLUX_WORKSPACE_ID` (and the id in the `<coflux-session>` block from earlier in this session)
142
- still names where the terminal was *opened*; it is frozen when the PTY starts and cannot be
143
- rewritten. Never reuse it after a move.
146
+ - `COFLUX_WORKSPACE_ID` (and the workspace id and handle in the `<coflux-session>` block from
147
+ earlier in this session) still names where the terminal was *opened*; it is frozen when the PTY
148
+ starts and cannot be rewritten. Never reuse either form after a move.
144
149
  - The shell inside this terminal keeps its own directory. That is only about the shell; it does not
145
150
  affect where your work is attributed.
146
151
 
@@ -152,15 +157,75 @@ a daemon that is down or too old — the session just carries on with the owners
152
157
 
153
158
  ```sh
154
159
  coflux workspace
155
- {"workspaceId":"ws-b","path":"/Users/me/.coflux/worktrees/ws-b","owningWorkspaceId":"ws-a","moved":true}
160
+ {"workspaceId":"3f2a1b7c-9d44-4e1a-8f02-1b6c2d5e7a90","ref":"coflux:workspace:3f2a1b7c","path":"/Users/me/.coflux/worktrees/ws-b","owningWorkspaceId":"7c1d90ab-5e33-4c88-9a71-0d4f8b2e6c15","owningRef":"coflux:workspace:7c1d90ab","moved":true}
156
161
  ```
157
162
 
158
163
  One line of JSON: `workspaceId` (+ `path`) is the **effective** workspace, `owningWorkspaceId` is the
159
- workspace this terminal belongs to right now, and `moved` says whether they differ. With the plugin
160
- installed you also get a `<coflux-session-moved>` block at the start of every prompt while the two
161
- differ — but that block only arrives with the **next** user prompt. **About to call an account command right
162
- after a `cd`? Run `coflux workspace` first** and use the `workspaceId` it prints; do not reuse
163
- `COFLUX_WORKSPACE_ID`.
164
+ workspace this terminal belongs to right now, `ref` and `owningRef` are those two workspaces'
165
+ handles (a daemon too old to send them leaves both keys out), and `moved` says whether they differ.
166
+ With the plugin installed you also get a `<coflux-session-moved>` block at the start of every prompt
167
+ while the two differ — but that block only arrives with the **next** user prompt. **About to call an
168
+ account command right after a `cd`? Run `coflux workspace` first** and use the `workspaceId` it
169
+ prints; do not reuse `COFLUX_WORKSPACE_ID`.
170
+
171
+ ## Entity handles: ids you can paste
172
+
173
+ Every coflux entity also has a **handle**: one short string that says both "this is coflux" and
174
+ "this is a device / project / workspace / terminal".
175
+
176
+ ```
177
+ coflux:device:b6767697
178
+ coflux:project:7a83f21e
179
+ coflux:workspace:3f2a1b7c
180
+ coflux:terminal:9e21c4d0
181
+ ```
182
+
183
+ The grammar is `coflux:<kind>:<short>`. `<kind>` is one of `device`, `project`, `workspace` and
184
+ `terminal`. `<short>` is the first 8 hexadecimal characters of the entity's UUID — its first
185
+ dash-delimited group. Handles are always generated in lowercase and are read case-insensitively.
186
+ There is nothing else encoded in one: no name, no timestamp, no device. UUIDs stay canonical; a
187
+ handle is another way to *write* an id, not a replacement for it.
188
+
189
+ **Anywhere an id is accepted, a handle is accepted.** That covers every account CLI command taking
190
+ a device, project, workspace or terminal id, the `--device` / `--workspace` filters of the list
191
+ commands, and every local terminal command taking a terminal id. `coflux terminal read
192
+ coflux:terminal:9e21c4d0` and `coflux terminal read <that terminal's full UUID>` are the same call.
193
+
194
+ **Anywhere an entity is returned, its handle comes back too**, in a `ref` field beside the
195
+ entity's unchanged id field. The ids in JSON — `taskId`, `workspaceId`, `deviceId` — are still full
196
+ UUIDs, and `ref` is the handle next to them; where one payload carries two ids of the same kind it
197
+ gets two keys, as `coflux workspace` does with `ref` and `owningRef`. Local terminal results carry
198
+ both as well, and their `taskId` is always the **resolved** UUID — never the handle you passed in,
199
+ so it is safe to feed onward. The local `coflux terminal list` text rows lead with the handle, so
200
+ the next command can be typed straight from what you just read.
201
+
202
+ So when the user pastes you a `coflux:` string, you already know what kind of thing it names and
203
+ can use it as-is — no listing, no guessing. Handles are made to be pasted rather than displayed:
204
+ the desktop and iOS clients offer "copy handle" on the entity (right-click in the desktop sidebar
205
+ and terminal tabs, long-press on iOS rows and terminal chips) and show no handle text otherwise.
206
+
207
+ ### When a handle does not resolve
208
+
209
+ A handle is a prefix, so resolving one can fail in three distinct ways. Each is one readable
210
+ sentence; what matters is which of the three you got:
211
+
212
+ - **Nothing matches** — no entity of that kind, within the scope that command can see, starts with
213
+ that short id. The handle belongs to another account, to a workspace you are not in, or to
214
+ something that no longer exists. List and pick again; re-running the same string cannot help.
215
+ - **More than one matches** — two entities of that kind share the short id, so nothing is acted on.
216
+ Use the full UUID instead. There is no "first match" fallback, and picking one yourself is
217
+ exactly what the error exists to prevent.
218
+ - **Wrong kind** — the handle is well-formed but names a different kind than the command expects
219
+ (a workspace handle where a terminal id goes). The command fails rather than act on an adjacent
220
+ entity; the fix is a handle of the expected kind, never a retry.
221
+
222
+ The scopes differ on purpose: account CLI commands resolve a handle among **the requesting
223
+ account's** entities, while local terminal commands resolve it among the terminals of **the
224
+ workspace your cwd is in** — the same boundary that already makes another workspace's terminal
225
+ indistinguishable from one that does not exist.
226
+
227
+ Handles need a daemon and a CLI new enough to know about them. An older one rejects a handle the
228
+ way it rejects any unrecognised id; there, use the full UUID and tell the user to upgrade.
164
229
 
165
230
  ## When to open a terminal
166
231
 
@@ -266,9 +331,10 @@ coflux terminal read <taskId> # the last 200 lines of the scrollback
266
331
  coflux terminal read <taskId> --lines=50
267
332
  ```
268
333
 
269
- `list` rows are `<taskId> <state>[ exit=<code>][ busy|idle][ last=<code>] <title>`: `running` /
270
- `exited` / `idle` is the terminal, `busy` or `idle` says whether a command is running in it right
271
- now, and `last=<code>` is the exit code of the last command that finished. `read` returns the tail
334
+ `list` rows are `<handle> <state>[ exit=<code>][ busy|idle][ last=<code>] <title>`: the first
335
+ column is the terminal's handle (see "Entity handles"), which every other terminal command accepts
336
+ as its id; `running` / `exited` / `idle` is the terminal, `busy` or `idle` says whether a command is
337
+ running in it right now, and `last=<code>` is the exit code of the last command that finished. `read` returns the tail
272
338
  of the terminal's **full scrollback** (well beyond one screen, up to the daemon's history limit),
273
339
  ANSI stripped, with a `# running` / `# exited exit=<code>` header. A freshly opened terminal can
274
340
  read back empty for a moment while the shell starts. Once the shell has exited only the last
@@ -400,7 +466,10 @@ there: the terminal is open all the same, use `read` and `send` with it; "busy"
400
466
  still running in that terminal, `wait` for it or `read` first; "unknown action terminal.run" = this
401
467
  machine's daemon is older than the CLI, tell the user to run `cofluxd update && cofluxd restart`
402
468
  (the terminal was opened as a plain shell, nothing was run); "daemon is not connected to the
403
- center" only appears on `new`/`list`/`ports`/`notify`, retry once it reconnects.
469
+ center" only appears on `new`/`list`/`ports`/`notify`, retry once it reconnects. A terminal handle
470
+ that names the wrong kind, or whose prefix matches more than one terminal in this workspace, has
471
+ its own sentence each — see "When a handle does not resolve"; an unknown handle shares the
472
+ "not in this workspace or does not exist" sentence above, on purpose.
404
473
 
405
474
  ## Account CLI: across workspaces and devices
406
475
 
@@ -412,6 +481,7 @@ never as a command argument. Account commands return JSON. A workspace ID identi
412
481
  coflux device list
413
482
  coflux device exec <deviceId> --cmd="<command>" [--cwd=<dir>] [--timeout=<seconds>]
414
483
  coflux project list --device <deviceId>
484
+ coflux project import <path> [--device <deviceId>] [--name <name>]
415
485
  coflux workspace list --device <deviceId>
416
486
  coflux workspace new --project <projectId> --branch <branch>
417
487
  coflux terminal new --workspace <workspaceId> --title <title> [--cmd <command>]
@@ -437,6 +507,31 @@ Read before sending; stop immediately when the user takes over. If a write times
437
507
  result before retrying. Exiting the CLI does not stop its terminals. Delete workspaces through
438
508
  `coflux workspace remove` so the filesystem and workspace records stay consistent.
439
509
 
510
+ ### Turn a repository into a project
511
+
512
+ ```sh
513
+ coflux project import "$PWD"
514
+ coflux project import /srv/checkouts/api --device <deviceId> --name api
515
+ ```
516
+
517
+ A repository that is not a project yet has no workspaces, so nothing else in this skill can reach
518
+ it: `workspace new` needs a `projectId`. This is the command that creates one, without the user
519
+ having to click Import in the desktop app.
520
+
521
+ `<path>` is required and must be **absolute or start with `~/`** — it is resolved on the target
522
+ device, so importing the current directory is written explicitly as `"$PWD"`. Any path inside the
523
+ repository imports the repository root. `--device` selects the machine and defaults to
524
+ `COFLUX_DEVICE_ID`; with neither, the command fails rather than guessing. `--name` overrides the
525
+ project name, which otherwise comes from the git remote and falls back to the directory name.
526
+
527
+ Success is one line of JSON: `projectId`, `name`, `repoPath`, `defaultBranch`, the main workspace's
528
+ `workspaceId` and `path`, and `alreadyImported`. A repository root on a device has exactly one
529
+ project, so importing the same repository twice returns the existing one with
530
+ `alreadyImported: true` and creates nothing — re-running it is safe. Failures are one sentence and a
531
+ non-zero exit: "不是 git 仓库" (the path is not inside a repository), "设备离线,无法执行该操作"
532
+ (that device is not connected), and a submitted-but-unfinished import tells you to check
533
+ `coflux project list`.
534
+
440
535
  ### Run one command on another machine
441
536
 
442
537
  ```sh