mainflow 0.41.0 → 0.43.0-alpha.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/dist/cli.js +755 -155
- package/dist/{dist-zSabglPq.js → dist-D5rZ0MQH.js} +71 -23
- package/dist/facts.d.ts +61 -12
- package/dist/facts.js +56 -13
- package/dist/grant-commands.d.ts +1 -1
- package/dist/grant-commands.js +1 -1
- package/dist/hooks.d.ts +5 -540
- package/dist/hooks.js +126 -92
- package/dist/{host-BAMZmRy8.js → host-BJvqZy-i.js} +1374 -489
- package/dist/host.d.ts +3 -2
- package/dist/host.js +1 -1
- package/dist/{index-Dk4fCI7q.d.ts → index-BqPQp6TF.d.ts} +2 -8
- package/dist/index-DRpamPge.d.ts +550 -0
- package/dist/launch.d.ts +6 -5
- package/dist/launch.js +323 -1
- package/dist/native/mainflow-swap-darwin-arm64 +0 -0
- package/dist/native/mainflow-swap-darwin-x64 +0 -0
- package/dist/{operation-DlR1P_Pt.js → operation-BhBbGauo.js} +610 -343
- package/dist/{ui-9uuI4Jif.js → ui-xxzmhAqF.js} +107 -98
- package/dist/version-Dvv4jwF1.js +6 -0
- package/dist/web/assets/{index-DvYj_6IC.js → index-B4w2iRCQ.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +2 -2
- package/dist/launch-CMeKJsNd.js +0 -327
package/dist/cli.js
CHANGED
|
@@ -1,30 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { P as UNTIL_FORMS, X as parseUntil, Y as parseRef, Z as refExample, ct as stampMs, it as untilProblem, n as CLOSED_STATUS } from "./dist-D5rZ0MQH.js";
|
|
3
|
+
import { $t as WriteError, At as MAINLINE_FILE, Bt as registeredName, Gt as nodeStorage, Ht as resolveHub, It as listHubs, Lt as notRegisteredMessage, Mt as requireSlug, Nt as hubAtRoot, O as loadSessions, Pt as hubHoldingFile, Q as HubError, Qt as DATA_DIR, Rt as readRegistry, S as sessionBriefing, Ut as unregisterHub, Vt as registryFile, Wt as userDataDir, X as applyOperation, Yt as realPath, Z as checkHubName, _t as MAX_FILE_BYTES, at as memberOfGroup, b as untilState, ct as unavailableHubs, d as migrateHubData, et as loadHub, f as migrateHub, g as RETRY_RULES, gt as MAX_CHANGE_PAGE, h as receiverFromEnv, jt as TASK_FILE, k as readSession, m as retroReportFromReceiver, mt as BLOB_HASH, p as retroReport, qt as dataFile, st as remoteMembers, tt as sectionText, u as groupPlan, ut as nowMinute, x as duplicateClaimLines, y as initHub, zt as registerHub } from "./host-BJvqZy-i.js";
|
|
4
4
|
import { t as listProgress } from "./progress-1BfPg2zN.js";
|
|
5
|
-
import { C as
|
|
5
|
+
import { A as hubName, C as readRemote, D as Unreachable, E as ServerError, F as syncPaths, M as SETTINGS_FILE, O as ENV, T as ServerApi, _ as DaemonUnavailable, a as versionGate, b as readDaemon, c as oversizedFiles, d as planAnnounce, f as planFetch, g as DaemonRunning, h as startDaemon, i as requireWritableVersion, j as syncSettings, k as earlierToken, l as fetchDenial, m as toolCallOf, n as hubWriter, o as queuedCount, p as planFlush, r as writtenLines, s as remoteMode, t as CONFIRM_MS, u as hookAnswer, v as callDaemon, w as HubRewritingError, x as silentHint, y as probeDaemon } from "./operation-BhBbGauo.js";
|
|
6
6
|
import { COMMAND_SPECS, OPTIONS } from "./facts.js";
|
|
7
|
+
import { t as CLI_VERSION } from "./version-Dvv4jwF1.js";
|
|
7
8
|
import { hookFallback, parsePayload, runHook } from "./hooks.js";
|
|
8
|
-
import {
|
|
9
|
+
import { exitCodeFor, launchUi, networkProbe, readUiRecord, startUiInBackground, watchHubGone } from "./launch.js";
|
|
9
10
|
import { randomBytes } from "node:crypto";
|
|
10
11
|
import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
11
12
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
import { parseArgs } from "node:util";
|
|
15
|
+
//#region src/migration.ts
|
|
16
|
+
/**
|
|
17
|
+
* What `mainflow migrate` prints of a migration's report, the same whether the hub's files are here or on the sync
|
|
18
|
+
* server: the formats, each file written and removed, and the open entries still missing a definition of done.
|
|
19
|
+
*/
|
|
20
|
+
function migrationLines(report, dryRun) {
|
|
21
|
+
return [
|
|
22
|
+
`format ${report.from} → ${report.to}${dryRun ? " (dry run, nothing written)" : ""}`,
|
|
23
|
+
...report.written.map((file) => ` ${dryRun ? "would write" : "wrote"} ${file}`),
|
|
24
|
+
...report.removed.map((file) => ` ${dryRun ? "would remove" : "removed"} ${file}`),
|
|
25
|
+
...report.missingDefinitions.length ? [`${report.missingDefinitions.length} open entries still need a definition of done; fill them in the files:`, ...report.missingDefinitions.map((entry) => ` ${entry}`)] : []
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
14
29
|
//#region src/remote/sync.ts
|
|
15
30
|
/**
|
|
16
31
|
* Run the sync client for the hub at `root` until the process is told to stop, or the hub's directory goes. A write
|
|
17
32
|
* that found no sync client may have started this one in the background (src/remote/start.ts); a clean stop clears
|
|
18
33
|
* that record, so the next write does not take the stop for a failed start.
|
|
19
34
|
*/
|
|
20
|
-
async function syncRun(
|
|
21
|
-
const settings =
|
|
22
|
-
if (!settings)
|
|
23
|
-
|
|
24
|
-
console.error(earlier ? `✗ ${earlier}` : `✗ no service token for the sync server: set ${ENV.clientId} and ${ENV.clientSecret}, or clientId and clientSecret in .mainflow/${SETTINGS_FILE}`);
|
|
25
|
-
return 1;
|
|
26
|
-
}
|
|
27
|
-
const running = await probeDaemon(root);
|
|
35
|
+
async function syncRun(dirs) {
|
|
36
|
+
const settings = serverOf(dirs);
|
|
37
|
+
if (!settings) return 1;
|
|
38
|
+
const running = await probeDaemon(dirs);
|
|
28
39
|
if (running && running.state !== "gone") {
|
|
29
40
|
console.error(`✗ mainflow sync already runs for this hub (pid ${running.daemon.pid})${running.state === "silent" ? `; ${silentHint(running.daemon)}` : ""}`);
|
|
30
41
|
return 1;
|
|
@@ -43,7 +54,7 @@ async function syncRun(root) {
|
|
|
43
54
|
let daemon;
|
|
44
55
|
try {
|
|
45
56
|
daemon = await startDaemon({
|
|
46
|
-
|
|
57
|
+
dirs,
|
|
47
58
|
settings,
|
|
48
59
|
log: (line) => console.log(line)
|
|
49
60
|
});
|
|
@@ -56,15 +67,15 @@ async function syncRun(root) {
|
|
|
56
67
|
await new Promise((resolve) => {
|
|
57
68
|
process.once("SIGINT", resolve);
|
|
58
69
|
process.once("SIGTERM", resolve);
|
|
59
|
-
watchHubGone(
|
|
70
|
+
watchHubGone(dirs, resolve);
|
|
60
71
|
});
|
|
61
72
|
await daemon.stop();
|
|
62
|
-
forgetStarted(
|
|
73
|
+
forgetStarted(dirs);
|
|
63
74
|
process.exit(0);
|
|
64
75
|
}
|
|
65
76
|
/** Clear the record of a background start when it names this process. */
|
|
66
|
-
function forgetStarted(
|
|
67
|
-
const file = syncPaths(
|
|
77
|
+
function forgetStarted(dirs) {
|
|
78
|
+
const file = syncPaths(dirs).started;
|
|
68
79
|
try {
|
|
69
80
|
if (JSON.parse(readFileSync(file, "utf8")).pid === process.pid) rmSync(file, { force: true });
|
|
70
81
|
} catch {}
|
|
@@ -78,57 +89,89 @@ function readPayload() {
|
|
|
78
89
|
}
|
|
79
90
|
}
|
|
80
91
|
/**
|
|
81
|
-
*
|
|
82
|
-
* payload
|
|
83
|
-
*
|
|
92
|
+
* The hub a sync hook acts on, from its payload: the registered hub whose data directory holds the file the tool names
|
|
93
|
+
* (tool_input.file_path, relative to the payload's cwd), or, for a tool that names no file, such as Bash, the hub of the
|
|
94
|
+
* payload's cwd, then of CLAUDE_PROJECT_DIR. Null when there is none, or the registry cannot be read: the hook then
|
|
95
|
+
* answers nothing, so the forwarding script can hand the CLI every call without deciding which ones touch a hub.
|
|
96
|
+
* A hub found is checked by the version gate as the `hook` command checks its hub: a version outside the range the
|
|
97
|
+
* hub's config.yaml names is warned about on stderr and never blocks the hook.
|
|
84
98
|
*/
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
const
|
|
99
|
+
function hookHub(payload, cwd, env = process.env) {
|
|
100
|
+
const base = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : cwd;
|
|
101
|
+
const file = payload.tool_input?.file_path;
|
|
102
|
+
let hub = null;
|
|
103
|
+
try {
|
|
104
|
+
if (typeof file === "string" && file) hub = hubHoldingFile(resolve(base, file), env);
|
|
105
|
+
else for (const dir of [base, env.CLAUDE_PROJECT_DIR]) {
|
|
106
|
+
hub = dir && existsSync(dir) ? resolveHub(dir, {
|
|
107
|
+
env,
|
|
108
|
+
waitForLock: false
|
|
109
|
+
}) : null;
|
|
110
|
+
if (hub) break;
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(`mainflow sync --hook: ${error.message}`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (hub) {
|
|
117
|
+
const verdict = versionGate(hub, CLI_VERSION, { writes: false });
|
|
118
|
+
if (verdict.kind !== "run") console.error(`! ${verdict.message}`);
|
|
119
|
+
}
|
|
120
|
+
return hub;
|
|
121
|
+
}
|
|
122
|
+
/** Send every change now and report what did not reach the hub as written. */
|
|
123
|
+
async function syncFlush(dirs) {
|
|
124
|
+
const daemon = readDaemon(dirs);
|
|
88
125
|
if (!daemon) {
|
|
89
|
-
if (hook) return 0;
|
|
90
126
|
console.error("✗ mainflow sync is not running for this hub");
|
|
91
127
|
return 1;
|
|
92
128
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
129
|
+
const result = await callDaemon(daemon, "/flush", {}, 3e4);
|
|
130
|
+
for (const problem of result.problems) console.log(`! ${problem.message}`);
|
|
131
|
+
console.log(`${result.problems.length} problems, ${result.queued} queued, ${result.online ? "online" : "offline"}`);
|
|
132
|
+
return result.problems.length > 0 ? 1 : 0;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* `sync flush --hook`: read the PostToolUse payload from stdin, skip tools that wrote nothing synced, and answer as the
|
|
136
|
+
* hook: a block with the problems as the reason, or nothing. A hook never fails the tool, so outside a registered
|
|
137
|
+
* hub, or without a running sync client, it answers nothing.
|
|
138
|
+
*/
|
|
139
|
+
async function syncFlushHook(cwd) {
|
|
140
|
+
const payload = readPayload();
|
|
141
|
+
const dirs = hookHub(payload, cwd);
|
|
142
|
+
const daemon = dirs ? readDaemon(dirs) : null;
|
|
143
|
+
if (!dirs || !daemon) return 0;
|
|
144
|
+
const plan = planFlush(dirs, payload);
|
|
145
|
+
if (plan.skip) return 0;
|
|
146
|
+
const call = toolCallOf(payload);
|
|
147
|
+
const body = {
|
|
148
|
+
...plan.edit ? { edit: plan.edit } : {
|
|
149
|
+
tool: true,
|
|
150
|
+
...plan.path === void 0 ? {} : { path: plan.path }
|
|
151
|
+
},
|
|
152
|
+
...call ? { call } : {}
|
|
153
|
+
};
|
|
106
154
|
let result;
|
|
107
155
|
try {
|
|
108
156
|
result = await callDaemon(daemon, "/flush", body, 3e4);
|
|
109
157
|
} catch (error) {
|
|
110
|
-
if (
|
|
158
|
+
if (error instanceof DaemonUnavailable) return 0;
|
|
111
159
|
throw error;
|
|
112
160
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return 0;
|
|
117
|
-
}
|
|
118
|
-
for (const problem of result.problems) console.log(`! ${problem.message}`);
|
|
119
|
-
console.log(`${result.problems.length} problems, ${result.queued} queued, ${result.online ? "online" : "offline"}`);
|
|
120
|
-
return result.problems.length > 0 ? 1 : 0;
|
|
161
|
+
const answer = hookAnswer(result);
|
|
162
|
+
if (answer) console.log(answer);
|
|
163
|
+
return 0;
|
|
121
164
|
}
|
|
122
165
|
/** How long a download may take before the command or the hook gives up on the sync client's answer. */
|
|
123
166
|
const FETCH_MS = 3e4;
|
|
124
167
|
/**
|
|
125
168
|
* What `target` names, as the sync client takes it: a mainline (m001) or a task (m001/t002), as its directory with every
|
|
126
|
-
* file under it, or a path
|
|
169
|
+
* file under it, or a path: from the current directory, as `.mainflow/{path}`, or from the hub's data directory. Null when it names nothing of the hub.
|
|
127
170
|
*/
|
|
128
|
-
function fetchSpec(
|
|
171
|
+
function fetchSpec(dirs, target) {
|
|
129
172
|
const ref = /^(m\d{3})(?:\/(t\d{3}))?$/.exec(target);
|
|
130
173
|
if (ref) {
|
|
131
|
-
const dirOf = (parent, id) => existsSync(join(
|
|
174
|
+
const dirOf = (parent, id) => existsSync(join(dirs.data, parent)) ? readdirSync(join(dirs.data, parent)).find((name) => name.startsWith(`${id}-`)) : void 0;
|
|
132
175
|
const mainline = dirOf("mainlines", ref[1]);
|
|
133
176
|
if (!mainline) return null;
|
|
134
177
|
if (!ref[2]) return `mainlines/${mainline}/`;
|
|
@@ -136,33 +179,34 @@ function fetchSpec(root, target) {
|
|
|
136
179
|
return task ? `mainlines/${mainline}/tasks/${task}/` : null;
|
|
137
180
|
}
|
|
138
181
|
const inHub = (file) => {
|
|
139
|
-
const path = relative(
|
|
182
|
+
const path = relative(dirs.data, file).split(sep).join("/");
|
|
140
183
|
return path === "" || path.startsWith("../") || path === ".." || isAbsolute(path) ? null : path;
|
|
141
184
|
};
|
|
142
|
-
|
|
185
|
+
if (isAbsolute(target)) return inHub(target);
|
|
186
|
+
const named = target === ".mainflow" || target.startsWith(`.mainflow/`) ? dataFile(dirs, target) : join(dirs.data, target);
|
|
187
|
+
return inHub(resolve(process.cwd(), target)) ?? inHub(named);
|
|
143
188
|
}
|
|
144
189
|
/**
|
|
145
190
|
* Download files only on the sync server, the artifacts of closed mainlines an environment with on-demand downloads
|
|
146
|
-
* left there: one file, a directory, or a mainline's or task's, by its ID.
|
|
147
|
-
* stdin instead: a Read, Edit, Write or MultiEdit of an artifact that is not here downloads it first, when the host runs
|
|
191
|
+
* left there: one file, a directory, or a mainline's or task's, by its ID. As a hook (syncFetchHook), it reads the
|
|
192
|
+
* PreToolUse payload from stdin instead: a Read, Edit, Write or MultiEdit of an artifact that is not here downloads it first, when the host runs
|
|
148
193
|
* the hook for it (Claude Code answers an Edit of a missing file before its hooks). A Read then goes ahead; a write is
|
|
149
194
|
* stopped with the reason, so the agent reads the file before writing over it. When it cannot be
|
|
150
195
|
* downloaded, or no sync client runs, the tool is stopped with the reason, so the agent does not take the file for one
|
|
151
196
|
* that does not exist. An Edit of one replacement of a synced file here is announced to the sync client. Anything else
|
|
152
197
|
* answers nothing.
|
|
153
198
|
*/
|
|
154
|
-
async function syncFetch(
|
|
155
|
-
if (hook) return syncFetchHook(root, readPayload());
|
|
199
|
+
async function syncFetch(dirs, target) {
|
|
156
200
|
if (target === void 0) {
|
|
157
201
|
console.error("✗ Usage: mainflow sync fetch <path|m|m/t> (or --hook for a PreToolUse hook)");
|
|
158
202
|
return 1;
|
|
159
203
|
}
|
|
160
|
-
const spec = fetchSpec(
|
|
204
|
+
const spec = fetchSpec(dirs, target);
|
|
161
205
|
if (spec === null) {
|
|
162
206
|
console.error(`✗ ${target} names no mainline, task or path in .mainflow/`);
|
|
163
207
|
return 1;
|
|
164
208
|
}
|
|
165
|
-
const daemon = readDaemon(
|
|
209
|
+
const daemon = readDaemon(dirs);
|
|
166
210
|
if (!daemon) {
|
|
167
211
|
console.error("✗ mainflow sync is not running for this hub; start it with mainflow sync, then fetch again");
|
|
168
212
|
return 1;
|
|
@@ -192,9 +236,9 @@ const ANNOUNCE_MS = 2e3;
|
|
|
192
236
|
* rather than being taken for a rewrite of an older version (docs/sync.md "等 Edit 的回報"). Silent whatever happens:
|
|
193
237
|
* without a sync client, or with one older than the announcement, which answers 404, the change is sent as unannounced.
|
|
194
238
|
*/
|
|
195
|
-
async function announceEdit(
|
|
196
|
-
const edit = planAnnounce(
|
|
197
|
-
const daemon = edit ? readDaemon(
|
|
239
|
+
async function announceEdit(dirs, payload) {
|
|
240
|
+
const edit = planAnnounce(dirs, payload);
|
|
241
|
+
const daemon = edit ? readDaemon(dirs) : null;
|
|
198
242
|
if (!edit || !daemon) return 0;
|
|
199
243
|
try {
|
|
200
244
|
const call = toolCallOf(payload);
|
|
@@ -205,14 +249,24 @@ async function announceEdit(root, payload) {
|
|
|
205
249
|
} catch {}
|
|
206
250
|
return 0;
|
|
207
251
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
252
|
+
/**
|
|
253
|
+
* `sync fetch --hook`: read the PreToolUse payload from stdin and act on the file it names when that file is in a
|
|
254
|
+
* registered hub's data directory (hookHub); any other call answers nothing.
|
|
255
|
+
*/
|
|
256
|
+
async function syncFetchHook(cwd) {
|
|
257
|
+
const payload = readPayload();
|
|
258
|
+
const file = payload.tool_input?.file_path;
|
|
259
|
+
const dirs = typeof file === "string" && file ? hookHub(payload, cwd) : null;
|
|
260
|
+
return dirs ? fetchAsHook(dirs, payload) : 0;
|
|
261
|
+
}
|
|
262
|
+
async function fetchAsHook(dirs, payload) {
|
|
263
|
+
const plan = planFetch(dirs, payload);
|
|
264
|
+
if (!plan) return announceEdit(dirs, payload);
|
|
211
265
|
const deny = (reason) => {
|
|
212
266
|
console.log(fetchDenial(reason));
|
|
213
267
|
return 0;
|
|
214
268
|
};
|
|
215
|
-
const daemon = readDaemon(
|
|
269
|
+
const daemon = readDaemon(dirs);
|
|
216
270
|
let result = null;
|
|
217
271
|
let busy = false;
|
|
218
272
|
if (daemon) try {
|
|
@@ -221,7 +275,7 @@ async function syncFetchHook(root, payload) {
|
|
|
221
275
|
busy = error instanceof DaemonUnavailable && error.silent;
|
|
222
276
|
}
|
|
223
277
|
if (!result) {
|
|
224
|
-
if (!(plan.path in readRemote(
|
|
278
|
+
if (!(plan.path in readRemote(dirs))) return 0;
|
|
225
279
|
if (busy) return deny(`.mainflow/${plan.path} is only on the sync server and is not downloaded yet: mainflow sync did not answer within ${FETCH_MS / 1e3} s, as it is busy, such as with a large snapshot. Try again shortly, or run mainflow sync fetch ${plan.path}; the file exists, do not take it for missing.`);
|
|
226
280
|
return deny(`.mainflow/${plan.path} is only on the sync server and is not downloaded here, and mainflow sync is not running, so it cannot be downloaded now. Start mainflow sync and run mainflow sync fetch ${plan.path}; the file exists, do not take it for missing.`);
|
|
227
281
|
}
|
|
@@ -234,8 +288,8 @@ async function syncFetchHook(root, payload) {
|
|
|
234
288
|
if (plan.write) return deny(`.mainflow/${plan.path} was only on the sync server and is downloaded now. Read it before writing to it, so the write starts from what it holds.`);
|
|
235
289
|
return 0;
|
|
236
290
|
}
|
|
237
|
-
async function syncStatus(
|
|
238
|
-
const probe = await probeDaemon(
|
|
291
|
+
async function syncStatus(dirs) {
|
|
292
|
+
const probe = await probeDaemon(dirs);
|
|
239
293
|
if (probe?.state === "silent") {
|
|
240
294
|
console.log(`mainflow sync runs for this hub (pid ${probe.daemon.pid}), but ${silentHint(probe.daemon)}`);
|
|
241
295
|
return 0;
|
|
@@ -247,12 +301,403 @@ async function syncStatus(root) {
|
|
|
247
301
|
if (status?.waitingForUpload) console.log("! the sync server has no copy of this hub, and this environment downloads on demand, so it does not upload its copy; run mainflow sync with on-demand downloads off in an environment that keeps every file of the hub to upload it");
|
|
248
302
|
return 0;
|
|
249
303
|
}
|
|
250
|
-
const queue = queuedCount(
|
|
251
|
-
console.log(`mainflow sync is not running; ${remoteMode(
|
|
252
|
-
const earlier = remoteMode(
|
|
304
|
+
const queue = queuedCount(dirs);
|
|
305
|
+
console.log(`mainflow sync is not running; ${remoteMode(dirs) ? `${queue} write(s) wait in the outbox until it runs` : "writes go to the local files"}`);
|
|
306
|
+
const earlier = remoteMode(dirs) ? null : earlierToken(dirs);
|
|
253
307
|
if (earlier) console.error(`! ${earlier}`);
|
|
254
308
|
return 0;
|
|
255
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* The service token and the server of the hub at `root`, with this CLI's version for every request to say
|
|
312
|
+
* (VERSION_HEADER), or null after saying why there are none.
|
|
313
|
+
*/
|
|
314
|
+
function serverOf(dirs) {
|
|
315
|
+
const settings = syncSettings(dirs);
|
|
316
|
+
if (settings) return {
|
|
317
|
+
...settings,
|
|
318
|
+
version: CLI_VERSION
|
|
319
|
+
};
|
|
320
|
+
const earlier = earlierToken(dirs);
|
|
321
|
+
console.error(earlier ? `✗ ${earlier}` : `✗ no service token for the sync server: set ${ENV.clientId} and ${ENV.clientSecret}, or clientId and clientSecret in .mainflow/${SETTINGS_FILE}`);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
/** What `target` names on the server: a mainline's or task's directory, or a path; null for the whole hub, undefined when it names nothing. */
|
|
325
|
+
function historyPath(dirs, target) {
|
|
326
|
+
if (target === void 0) return null;
|
|
327
|
+
const spec = fetchSpec(dirs, target);
|
|
328
|
+
if (spec === null) {
|
|
329
|
+
console.error(`✗ ${target} names no mainline, task or path in .mainflow/`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
return spec.replace(/\/+$/, "") || null;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Run `call` against the sync server, saying why it failed: unreachable, refused, or a server from before the route,
|
|
336
|
+
* which lacks `lacking`, like "change log".
|
|
337
|
+
*/
|
|
338
|
+
async function onServer(settings, call, lacking = "change log") {
|
|
339
|
+
const api = new ServerApi(settings);
|
|
340
|
+
try {
|
|
341
|
+
return await call(api);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (error instanceof ServerError && error.status === 404 && !/hub name/.test(error.message)) {
|
|
344
|
+
console.error(`✗ the sync server at ${settings.url} has no ${lacking}, as one from before it does not; deploy the sync server of this mainflow version (apps/sync-server)`);
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
if (error instanceof ServerError || error instanceof Unreachable) {
|
|
348
|
+
console.error(`✗ ${error.message} (hub ${settings.hub} at ${settings.url})`);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
throw error;
|
|
352
|
+
} finally {
|
|
353
|
+
api.close();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** A time of the server's in this machine's time zone, to the second, as `ops list` shows them to the minute. */
|
|
357
|
+
function localTime(iso) {
|
|
358
|
+
const d = new Date(iso);
|
|
359
|
+
return `${d.toLocaleDateString("sv-SE")}T${d.toTimeString().slice(0, 8)}`;
|
|
360
|
+
}
|
|
361
|
+
/** What a path held, short: a hash's first 7 digits, `dir`, or `-` for nothing. */
|
|
362
|
+
const shortState = (state) => state === null ? "-" : state === "dir" ? "dir" : state.slice(0, 7);
|
|
363
|
+
/** What a change did to its path, in a word. */
|
|
364
|
+
function changeKind(before, after) {
|
|
365
|
+
const dir = before === "dir" || after === "dir";
|
|
366
|
+
if (before === null) return dir ? "dir created" : "created";
|
|
367
|
+
if (after === null) return dir ? "dir removed" : "removed";
|
|
368
|
+
if (BLOB_HASH.test(after) !== BLOB_HASH.test(before)) return BLOB_HASH.test(after) ? "made binary" : "made text";
|
|
369
|
+
return "changed";
|
|
370
|
+
}
|
|
371
|
+
/** Who sent a change: the first 8 characters of a service token's Client ID, or an email whole; `(here)` for this environment's. */
|
|
372
|
+
function sourceLabel(source, settings) {
|
|
373
|
+
if (source === null) return "?";
|
|
374
|
+
const short = source.includes("@") ? source : source.slice(0, 8);
|
|
375
|
+
return source === settings.clientId ? `${short} (here)` : short;
|
|
376
|
+
}
|
|
377
|
+
/** How many changes `sync log` lists unless --limit says otherwise. */
|
|
378
|
+
const LOG_LIMIT = 50;
|
|
379
|
+
/**
|
|
380
|
+
* List the changes the sync server logged, newest first: of a file or directory, a mainline's or a task's, or of the
|
|
381
|
+
* whole hub. Each line is the change's seq, when the server made it, who sent it, the request, what it did and the
|
|
382
|
+
* path, with what the path held before and after; --json prints the server's page as it is.
|
|
383
|
+
*/
|
|
384
|
+
async function syncLog(dirs, target, opts) {
|
|
385
|
+
const limit = opts.limit === void 0 ? LOG_LIMIT : Number(opts.limit);
|
|
386
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
387
|
+
console.error(`✗ --limit takes a whole number of changes above 0, e.g. --limit 100; got ${opts.limit}`);
|
|
388
|
+
return 1;
|
|
389
|
+
}
|
|
390
|
+
const path = historyPath(dirs, target);
|
|
391
|
+
const settings = path === void 0 ? null : serverOf(dirs);
|
|
392
|
+
if (path === void 0 || !settings) return 1;
|
|
393
|
+
const page = await onServer(settings, async (api) => {
|
|
394
|
+
const changes = [];
|
|
395
|
+
let first = null;
|
|
396
|
+
let after;
|
|
397
|
+
while (changes.length < limit && after !== null) {
|
|
398
|
+
const next = await api.changes({
|
|
399
|
+
...path === null ? {} : { path },
|
|
400
|
+
...after === void 0 ? {} : { after },
|
|
401
|
+
limit: limit - changes.length
|
|
402
|
+
});
|
|
403
|
+
first ??= next;
|
|
404
|
+
changes.push(...next.changes);
|
|
405
|
+
after = next.next;
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
...first,
|
|
409
|
+
changes,
|
|
410
|
+
next: after ?? null
|
|
411
|
+
};
|
|
412
|
+
});
|
|
413
|
+
if (!page) return 1;
|
|
414
|
+
if (opts.json) {
|
|
415
|
+
console.log(JSON.stringify(page, null, 2));
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
for (const change of page.changes) {
|
|
419
|
+
const op = `${change.op ?? "?"}${change.batch === void 0 ? "" : `#${change.batch}`}`;
|
|
420
|
+
console.log(` ${change.seq} ${localTime(change.at)} ${sourceLabel(change.source, settings)} ${op} ${changeKind(change.before, change.after)} ${change.path} ${shortState(change.before)} → ${shortState(change.after)}`);
|
|
421
|
+
}
|
|
422
|
+
const more = page.next === null ? "" : `; older ones with --limit ${limit * 2}`;
|
|
423
|
+
console.log(`✓ ${page.changes.length} change${page.changes.length === 1 ? "" : "s"}${more}. The hub is at seq ${page.seq}, and can be put back to seq ${page.from} (${localTime(page.fromAt)}) or later`);
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
/** How long `mainflow migrate` and `sync restore` wait before asking how far a rewrite in batches got: first, then each time after. */
|
|
427
|
+
const REWRITE_POLL_FIRST_MS = 2e3;
|
|
428
|
+
const REWRITE_POLL_MS = 5e3;
|
|
429
|
+
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
430
|
+
/** Where a rewrite in batches is at, in words: its phase, its batches and the paths they changed. */
|
|
431
|
+
function rewriteProgress(rewrite) {
|
|
432
|
+
const what = rewrite.kind === "migrate" ? "migrating" : "putting back";
|
|
433
|
+
const planned = rewrite.planned === void 0 ? "" : ` of ${rewrite.planned}`;
|
|
434
|
+
return ` … ${what}: ${rewrite.phase}, ${rewrite.batches} batch${rewrite.batches === 1 ? "" : "es"} written, ${rewrite.changed}${planned} paths changed`;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Wait for the rewrite in batches `rewrite` to end, asking the server how far it got: after REWRITE_POLL_FIRST_MS, then
|
|
438
|
+
* every REWRITE_POLL_MS, printing where it is at each time it moved on. The rewrite as it ended; one the server tells of
|
|
439
|
+
* no more, since another started after it, is taken as done. A server that cannot be asked throws, as any call does; the
|
|
440
|
+
* rewrite goes on regardless, and `mainflow sync log` shows its batches.
|
|
441
|
+
*/
|
|
442
|
+
async function awaitRewrite(api, rewrite) {
|
|
443
|
+
let last = "";
|
|
444
|
+
for (let wait = REWRITE_POLL_FIRST_MS;; wait = REWRITE_POLL_MS) {
|
|
445
|
+
const line = rewriteProgress(rewrite);
|
|
446
|
+
if (line !== last) console.log(line);
|
|
447
|
+
last = line;
|
|
448
|
+
await sleep$1(wait);
|
|
449
|
+
const now = (await api.rewrite()).rewrite;
|
|
450
|
+
if (now === null || now.startedAt !== rewrite.startedAt) return {
|
|
451
|
+
...rewrite,
|
|
452
|
+
state: "done"
|
|
453
|
+
};
|
|
454
|
+
rewrite = now;
|
|
455
|
+
if (rewrite.state !== "running") return rewrite;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** The changes a rewrite in batches made: the hub's log from `rewrite.from` on to `rewrite.seq`, oldest first. */
|
|
459
|
+
async function rewriteChanges(api, rewrite) {
|
|
460
|
+
const changes = [];
|
|
461
|
+
for (let after; after !== null;) {
|
|
462
|
+
const page = await api.changes({
|
|
463
|
+
...after === void 0 ? {} : { after },
|
|
464
|
+
limit: MAX_CHANGE_PAGE
|
|
465
|
+
});
|
|
466
|
+
changes.push(...page.changes.filter((change) => change.seq > rewrite.from && change.seq <= rewrite.seq));
|
|
467
|
+
after = page.changes.some((change) => change.seq <= rewrite.from) ? null : page.next;
|
|
468
|
+
}
|
|
469
|
+
return changes.reverse();
|
|
470
|
+
}
|
|
471
|
+
/** What a list of a rewrite holds past the REWRITE_LISTED it names, in words, or nothing. */
|
|
472
|
+
const unlisted = (named, all, what) => all !== void 0 && all > named ? [` … and ${all - named} more ${what}`] : [];
|
|
473
|
+
/** What a restore did, or would do, to one path, in words. */
|
|
474
|
+
const restoredLine = (step) => ` ${changeKind(step.from, step.to)} ${step.path} ${shortState(step.from)} → ${shortState(step.to)}`;
|
|
475
|
+
/**
|
|
476
|
+
* Put the hub, a mainline's or task's directory, or a path, back to what it held after a seq (--seq) or at a time
|
|
477
|
+
* (--at, this machine's local time unless it names its offset), as a new change on the sync server, which the sync
|
|
478
|
+
* clients take in. A path whose content the server no longer holds (missing), or one something outside the restore
|
|
479
|
+
* holds the place of (kept: a directory something the log does not name is in, or a path below a file outside a
|
|
480
|
+
* restore of one path), is left as it is and makes the command fail, after the rest is put back. --dry-run lists what
|
|
481
|
+
* it would change and leave, from the restore applied and rolled back on the server, and fails as the restore would.
|
|
482
|
+
*/
|
|
483
|
+
async function syncRestore(dirs, target, opts) {
|
|
484
|
+
if (opts.seq === void 0 === (opts.at === void 0)) {
|
|
485
|
+
console.error("✗ name what to put the hub back to, either --seq <n>, a seq of mainflow sync log, or --at <time>, like --at 2026-10-19T15:30");
|
|
486
|
+
return 1;
|
|
487
|
+
}
|
|
488
|
+
const seq = opts.seq === void 0 ? void 0 : Number(opts.seq);
|
|
489
|
+
if (seq !== void 0 && !(Number.isInteger(seq) && seq >= 0)) {
|
|
490
|
+
console.error(`✗ --seq takes a seq of mainflow sync log, a whole number; got ${opts.seq}`);
|
|
491
|
+
return 1;
|
|
492
|
+
}
|
|
493
|
+
const at = opts.at === void 0 ? void 0 : Date.parse(opts.at);
|
|
494
|
+
if (at !== void 0 && Number.isNaN(at)) {
|
|
495
|
+
console.error(`✗ --at takes a time, this machine's local time unless it names its offset, like 2026-10-19T15:30 or 2026-10-19T15:30+08:00; got ${opts.at}`);
|
|
496
|
+
return 1;
|
|
497
|
+
}
|
|
498
|
+
const path = historyPath(dirs, target);
|
|
499
|
+
const settings = path === void 0 ? null : serverOf(dirs);
|
|
500
|
+
if (path === void 0 || !settings) return 1;
|
|
501
|
+
const request = {
|
|
502
|
+
...seq === void 0 ? { at } : { seq },
|
|
503
|
+
...path === null ? {} : { path }
|
|
504
|
+
};
|
|
505
|
+
const scope = path === null ? "the hub" : path;
|
|
506
|
+
const result = await onServer(settings, async (api) => {
|
|
507
|
+
if (opts.dryRun) {
|
|
508
|
+
const pages = [];
|
|
509
|
+
for (let after;;) {
|
|
510
|
+
const page = await api.restore({
|
|
511
|
+
...request,
|
|
512
|
+
dryRun: true,
|
|
513
|
+
...after === void 0 ? {} : { after }
|
|
514
|
+
});
|
|
515
|
+
if (!page.ok) return page.message;
|
|
516
|
+
if (page.value === "started") return `the sync server set off a restore for a dry run: ${page.rewrite.kind}`;
|
|
517
|
+
pages.push(page);
|
|
518
|
+
after = page.next;
|
|
519
|
+
if (after !== void 0) continue;
|
|
520
|
+
const { next: _next, ...first } = pages[0];
|
|
521
|
+
return {
|
|
522
|
+
...first,
|
|
523
|
+
files: pages.flatMap((p) => p.files),
|
|
524
|
+
missing: pages.flatMap((p) => p.missing),
|
|
525
|
+
kept: pages.flatMap((p) => p.kept)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const answer = await api.restore(request);
|
|
530
|
+
if (!answer.ok) return answer.message;
|
|
531
|
+
if (answer.value !== "started") return answer;
|
|
532
|
+
if (!opts.json) console.log(` the sync server puts ${scope} back in batches; writes wait until it is done`);
|
|
533
|
+
return awaitRewrite(api, answer.rewrite).then(async (rewrite) => {
|
|
534
|
+
if (rewrite.state !== "done" || opts.json) return rewrite;
|
|
535
|
+
for (const change of await rewriteChanges(api, rewrite)) console.log(restoredLine({
|
|
536
|
+
path: change.path,
|
|
537
|
+
from: change.before,
|
|
538
|
+
to: change.after
|
|
539
|
+
}));
|
|
540
|
+
return rewrite;
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
if (result === null) return 1;
|
|
544
|
+
if (typeof result === "string") {
|
|
545
|
+
console.error(`✗ the sync server refused the restore: ${result}`);
|
|
546
|
+
return 1;
|
|
547
|
+
}
|
|
548
|
+
if ("kind" in result) return restoreEnded(dirs, target, scope, result, opts.json ?? false);
|
|
549
|
+
if (opts.json) {
|
|
550
|
+
console.log(JSON.stringify(result, null, 2));
|
|
551
|
+
return result.missing.length + result.kept.length > 0 ? 1 : 0;
|
|
552
|
+
}
|
|
553
|
+
const count = (n) => `${n} path${n === 1 ? "" : "s"}`;
|
|
554
|
+
for (const step of result.files) console.log(restoredLine(step));
|
|
555
|
+
const left = result.value === "planned" ? "would be left" : "was left";
|
|
556
|
+
for (const step of result.missing) console.log(missingLine(step, result.to, left));
|
|
557
|
+
for (const kept of result.kept) console.log(keptLine(kept, left));
|
|
558
|
+
if (result.value === "planned") {
|
|
559
|
+
console.log(`✓ putting ${scope} back to seq ${result.to} would change ${count(result.files.length)}; nothing was changed`);
|
|
560
|
+
return result.missing.length + result.kept.length > 0 ? 1 : 0;
|
|
561
|
+
}
|
|
562
|
+
if (result.value === "unchanged") console.log(`✓ ${scope} already holds what it held at seq ${result.to}; nothing was changed`);
|
|
563
|
+
else {
|
|
564
|
+
const undo = result.wholeAt === void 0 ? `; undo it with mainflow sync restore${target === void 0 ? "" : ` ${target}`} --seq ${result.seq - 1}` : "";
|
|
565
|
+
console.log(`✓ put ${scope} back to seq ${result.to}: ${count(result.files.length)} changed, as seq ${result.seq}${undo}`);
|
|
566
|
+
if (result.bookmark) console.log(bookmarkLine(result.bookmark));
|
|
567
|
+
if (!readDaemon(dirs)) console.log("! mainflow sync is not running for this hub; this copy takes the restore in once it runs");
|
|
568
|
+
}
|
|
569
|
+
return result.missing.length + result.kept.length > 0 ? 1 : 0;
|
|
570
|
+
}
|
|
571
|
+
/** A path a restore left, or would leave, since the server no longer holds what it held at `to`. */
|
|
572
|
+
const missingLine = (step, to, left) => `! ${step.path}: the sync server no longer holds what it held at seq ${to} (${shortState(step.to)}), so it ${left} as it is`;
|
|
573
|
+
/** A path a restore left, or would leave, since something outside it holds its place. */
|
|
574
|
+
const keptLine = (kept, left) => `! ${kept} ${left} as it is: something outside what is put back holds its place, a file above it or, in a directory, something the change log does not name`;
|
|
575
|
+
const bookmarkLine = (bookmark) => ` the Durable Object's point-in-time recovery bookmark from before it, kept 30 days: ${bookmark}`;
|
|
576
|
+
/**
|
|
577
|
+
* What a restore in batches came to, once it ended: the paths it left, and the restore that undoes it; or why it did
|
|
578
|
+
* not finish, and how to go on. --json prints the rewrite as the server tells it. Fails as a restore done at once does.
|
|
579
|
+
*/
|
|
580
|
+
function restoreEnded(dirs, target, scope, rewrite, json) {
|
|
581
|
+
const missing = rewrite.missing ?? [];
|
|
582
|
+
const kept = rewrite.kept ?? [];
|
|
583
|
+
const failing = rewrite.state !== "done" || missing.length + kept.length > 0 ? 1 : 0;
|
|
584
|
+
if (json) {
|
|
585
|
+
console.log(JSON.stringify(rewrite, null, 2));
|
|
586
|
+
return failing;
|
|
587
|
+
}
|
|
588
|
+
if (rewrite.state !== "done") {
|
|
589
|
+
console.error(`✗ the restore on the sync server ${rewrite.state === "failed" ? "stopped partway" : "was refused"}: ${rewrite.message ?? ""}`);
|
|
590
|
+
const retry = `mainflow sync restore${target === void 0 ? "" : ` ${target}`} --seq ${rewrite.to}`;
|
|
591
|
+
if (rewrite.state === "failed") console.error(` writes wait until it goes through: try it again with ${retry}, or put the whole hub back to before it with mainflow sync restore --seq ${rewrite.wholeAt ?? rewrite.from}`);
|
|
592
|
+
return 1;
|
|
593
|
+
}
|
|
594
|
+
for (const step of missing) console.log(missingLine(step, rewrite.to, "was left"));
|
|
595
|
+
for (const line of unlisted(missing.length, rewrite.counts?.missing, "paths whose content the server no longer holds")) console.log(line);
|
|
596
|
+
for (const path of kept) console.log(keptLine(path, "was left"));
|
|
597
|
+
for (const line of unlisted(kept.length, rewrite.counts?.kept, "paths something outside the restore holds the place of")) console.log(line);
|
|
598
|
+
const undo = rewrite.wholeAt === void 0 ? `; undo it with mainflow sync restore${target === void 0 ? "" : ` ${target}`} --seq ${rewrite.from}` : "";
|
|
599
|
+
if (rewrite.batches === 0) console.log(`✓ ${scope} already holds what it held at seq ${rewrite.to}; nothing was changed`);
|
|
600
|
+
else console.log(`✓ put ${scope} back to seq ${rewrite.to}: ${rewrite.changed} paths changed, as seq ${rewrite.from + 1} to ${rewrite.seq} in ${rewrite.batches} batches${undo}`);
|
|
601
|
+
if (rewrite.bookmark) console.log(bookmarkLine(rewrite.bookmark));
|
|
602
|
+
if (!readDaemon(dirs)) console.log("! mainflow sync is not running for this hub; this copy takes the restore in once it runs");
|
|
603
|
+
return failing;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* `mainflow migrate` of a hub the sync server keeps: the server converts the hub between the writes of every
|
|
607
|
+
* environment, as one change, or for a hub of more paths than a batch of the server's in batches, each a change, while
|
|
608
|
+
* it holds every write off; the command waits for them. The sync clients take it in as a snapshot. It prints the report
|
|
609
|
+
* as a local migration does, and the command that puts the hub back to the seq before it. --dry-run lists what it would
|
|
610
|
+
* write and remove. A migration that stopped partway is resumed by running it again.
|
|
611
|
+
*/
|
|
612
|
+
async function syncMigrate(dirs, opts) {
|
|
613
|
+
const dryRun = opts.dryRun ?? false;
|
|
614
|
+
const settings = serverOf(dirs);
|
|
615
|
+
if (!settings) return 1;
|
|
616
|
+
const outcome = await onServer(settings, async (api) => {
|
|
617
|
+
const reports = [];
|
|
618
|
+
let answer;
|
|
619
|
+
let attached = false;
|
|
620
|
+
try {
|
|
621
|
+
for (let after;;) {
|
|
622
|
+
answer = await api.migrate({
|
|
623
|
+
...dryRun ? { dryRun } : {},
|
|
624
|
+
...after === void 0 ? {} : { after }
|
|
625
|
+
});
|
|
626
|
+
if (!answer.ok) return { refused: answer.message };
|
|
627
|
+
if (answer.value !== "planned" || !answer.report) break;
|
|
628
|
+
reports.push(answer.report);
|
|
629
|
+
after = answer.next;
|
|
630
|
+
if (after === void 0) break;
|
|
631
|
+
}
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (!(error instanceof HubRewritingError && error.rewrite.kind === "migrate" && error.rewrite.state === "running" && !dryRun)) throw error;
|
|
634
|
+
console.log(" the sync server is migrating the hub in batches already, as another environment asked; waiting for it");
|
|
635
|
+
answer = {
|
|
636
|
+
ok: true,
|
|
637
|
+
value: "started",
|
|
638
|
+
from: error.rewrite.from,
|
|
639
|
+
rewrite: error.rewrite
|
|
640
|
+
};
|
|
641
|
+
attached = true;
|
|
642
|
+
}
|
|
643
|
+
if (answer.value === "planned") return {
|
|
644
|
+
report: reports.length === 0 ? null : {
|
|
645
|
+
...reports[0],
|
|
646
|
+
written: reports.flatMap((r) => r.written),
|
|
647
|
+
removed: reports.flatMap((r) => r.removed),
|
|
648
|
+
missingDefinitions: reports.flatMap((r) => r.missingDefinitions)
|
|
649
|
+
},
|
|
650
|
+
seq: answer.seq,
|
|
651
|
+
from: answer.from
|
|
652
|
+
};
|
|
653
|
+
if (answer.value !== "started") return {
|
|
654
|
+
report: answer.report ?? null,
|
|
655
|
+
seq: answer.seq,
|
|
656
|
+
from: answer.from,
|
|
657
|
+
...answer.bookmark ? { bookmark: answer.bookmark } : {}
|
|
658
|
+
};
|
|
659
|
+
if (!attached) console.log(" the sync server migrates the hub in batches; writes wait until it is done");
|
|
660
|
+
const rewrite = await awaitRewrite(api, answer.rewrite);
|
|
661
|
+
if (rewrite.state !== "done") return { ended: rewrite };
|
|
662
|
+
const changes = await rewriteChanges(api, rewrite);
|
|
663
|
+
const file = (state) => state !== null && state !== "dir";
|
|
664
|
+
return {
|
|
665
|
+
report: {
|
|
666
|
+
from: rewrite.format ?? 3,
|
|
667
|
+
to: 3,
|
|
668
|
+
written: changes.filter((change) => file(change.after)).map((change) => `${DATA_DIR}/${change.path}`),
|
|
669
|
+
removed: changes.filter((change) => change.after === null && file(change.before)).map((change) => `${DATA_DIR}/${change.path}`),
|
|
670
|
+
missingDefinitions: [...rewrite.missingDefinitions ?? [], ...unlisted(rewrite.missingDefinitions?.length ?? 0, rewrite.counts?.missingDefinitions, "entries").map((line) => line.trim())]
|
|
671
|
+
},
|
|
672
|
+
seq: rewrite.seq,
|
|
673
|
+
from: rewrite.from,
|
|
674
|
+
batches: rewrite.batches,
|
|
675
|
+
...rewrite.bookmark ? { bookmark: rewrite.bookmark } : {}
|
|
676
|
+
};
|
|
677
|
+
}, "migration route");
|
|
678
|
+
if (!outcome) return 1;
|
|
679
|
+
if ("refused" in outcome) {
|
|
680
|
+
console.error(`✗ the sync server refused the migration: ${outcome.refused}`);
|
|
681
|
+
return 1;
|
|
682
|
+
}
|
|
683
|
+
if ("ended" in outcome) {
|
|
684
|
+
const { ended } = outcome;
|
|
685
|
+
if (ended.state === "refused") console.error(`✗ the sync server refused the migration: ${ended.message ?? ""}; the hub was left as it was`);
|
|
686
|
+
else console.error(`✗ the migration on the sync server stopped partway: ${ended.message ?? ""}\n writes wait until mainflow migrate goes on with it, or mainflow sync restore --seq ${ended.wholeAt ?? ended.from} puts the hub back to where it was before`);
|
|
687
|
+
return 1;
|
|
688
|
+
}
|
|
689
|
+
if (!outcome.report) {
|
|
690
|
+
console.log(`✓ the hub on the sync server is in the data format of this mainflow already; nothing was changed (seq ${outcome.seq})`);
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
for (const line of migrationLines(outcome.report, dryRun)) console.log(line);
|
|
694
|
+
if (dryRun) return 0;
|
|
695
|
+
const as = outcome.batches === void 0 ? `as seq ${outcome.seq}` : `as seq ${outcome.from + 1} to ${outcome.seq} in ${outcome.batches} batches`;
|
|
696
|
+
console.log(`✓ migrated the hub on the sync server ${as}; undo it with mainflow sync restore --seq ${outcome.from}`);
|
|
697
|
+
if (outcome.bookmark) console.log(bookmarkLine(outcome.bookmark));
|
|
698
|
+
if (!readDaemon(dirs)) console.log("! mainflow sync is not running for this hub; this copy takes the migration in once it runs");
|
|
699
|
+
return 0;
|
|
700
|
+
}
|
|
256
701
|
//#endregion
|
|
257
702
|
//#region src/retro.ts
|
|
258
703
|
const int = (n) => Math.round(n).toLocaleString("en-US");
|
|
@@ -528,22 +973,22 @@ const write = (spec) => spec;
|
|
|
528
973
|
* A group of another hub (`other-hub:w001`) must exist there when that hub is on this machine; the operation only
|
|
529
974
|
* checks that the hub is registered, since a hub kept on another machine cannot be read from here.
|
|
530
975
|
*/
|
|
531
|
-
const checkGroupOption = ({
|
|
976
|
+
const checkGroupOption = ({ dirs, opts }) => {
|
|
532
977
|
const ref = opts.group ? parseRef(opts.group) : null;
|
|
533
978
|
if (ref?.kind !== "group" || ref.hub === void 0) return;
|
|
534
|
-
const hub = loadHub(
|
|
979
|
+
const hub = loadHub(dirs);
|
|
535
980
|
if (ref.hub === hub.config.hub) return;
|
|
536
981
|
const remote = hub.remotes.find((r) => r.name === ref.hub);
|
|
537
982
|
if (remote?.hub && !remote.hub.groups.some((g) => g.id === ref.group)) throw new WriteError(`group ${ref.group} not found in hub "${ref.hub}"`);
|
|
538
983
|
};
|
|
539
|
-
/** Where a document an operation created or changed is, as the commands print it: .
|
|
540
|
-
const inData = (...parts) => join(
|
|
984
|
+
/** Where a document an operation created or changed is, as the commands print it: its absolute path in the hub's data directory. */
|
|
985
|
+
const inData = (dirs, ...parts) => join(dirs.data, ...parts);
|
|
541
986
|
const WRITES = {
|
|
542
987
|
"repo add": write({
|
|
543
988
|
op: "repo.add",
|
|
544
|
-
check: ({
|
|
989
|
+
check: ({ dirs, args }) => {
|
|
545
990
|
requireSlug(args[0]);
|
|
546
|
-
const target = resolve(root, args[1]);
|
|
991
|
+
const target = resolve(dirs.root, args[1]);
|
|
547
992
|
if (!existsSync(target) || !statSync(target).isDirectory()) throw new WriteError(`repo path ${args[1]} does not exist from the hub root (${target})`);
|
|
548
993
|
},
|
|
549
994
|
input: ({ args }) => ({
|
|
@@ -554,12 +999,13 @@ const WRITES = {
|
|
|
554
999
|
}),
|
|
555
1000
|
"hub add": write({
|
|
556
1001
|
op: "hub.add",
|
|
557
|
-
check: ({
|
|
1002
|
+
check: ({ dirs, args }) => {
|
|
558
1003
|
const [name, path] = [args[0], args[1]];
|
|
559
|
-
checkHubName(nodeStorage(
|
|
560
|
-
const target = resolve(root, path);
|
|
561
|
-
|
|
562
|
-
|
|
1004
|
+
checkHubName(nodeStorage(dirs), name);
|
|
1005
|
+
const target = resolve(dirs.root, path);
|
|
1006
|
+
const registered = hubAtRoot(target);
|
|
1007
|
+
if (!registered) throw new WriteError(`no registered hub at ${path} from the hub root (${target}); register that hub with mainflow hub register first`);
|
|
1008
|
+
const other = hubName(registered);
|
|
563
1009
|
if (other !== name) throw new WriteError(`${path} holds hub "${other}"; register it as "${other}"`);
|
|
564
1010
|
},
|
|
565
1011
|
input: ({ args }) => ({
|
|
@@ -630,9 +1076,9 @@ const WRITES = {
|
|
|
630
1076
|
serves: list(opts.serves),
|
|
631
1077
|
before: opts.before
|
|
632
1078
|
}),
|
|
633
|
-
print: ({
|
|
1079
|
+
print: ({ dirs }, ref) => {
|
|
634
1080
|
done(`created ${ref}`);
|
|
635
|
-
return remindDuplicates(
|
|
1081
|
+
return remindDuplicates(dirs, ref);
|
|
636
1082
|
}
|
|
637
1083
|
}),
|
|
638
1084
|
"action set": write({
|
|
@@ -656,10 +1102,10 @@ const WRITES = {
|
|
|
656
1102
|
}
|
|
657
1103
|
};
|
|
658
1104
|
},
|
|
659
|
-
print: ({
|
|
1105
|
+
print: ({ dirs, args, opts }, action) => {
|
|
660
1106
|
const ref = actionRef(opts, args[0]);
|
|
661
1107
|
done(`updated ${ref} (${action.status})`);
|
|
662
|
-
return opts.status === "doing" ? remindDuplicates(
|
|
1108
|
+
return opts.status === "doing" ? remindDuplicates(dirs, ref) : 0;
|
|
663
1109
|
}
|
|
664
1110
|
}),
|
|
665
1111
|
"new mainline": write({
|
|
@@ -671,7 +1117,7 @@ const WRITES = {
|
|
|
671
1117
|
group: opts.group,
|
|
672
1118
|
priority: opts.priority
|
|
673
1119
|
}),
|
|
674
|
-
print: (
|
|
1120
|
+
print: ({ dirs }, { id, dir }) => done(`created ${id} ${inData(dirs, dir, MAINLINE_FILE)}`)
|
|
675
1121
|
}),
|
|
676
1122
|
"new task": write({
|
|
677
1123
|
op: "task.new",
|
|
@@ -683,7 +1129,7 @@ const WRITES = {
|
|
|
683
1129
|
parent: opts.parent,
|
|
684
1130
|
repo: opts.repo
|
|
685
1131
|
}),
|
|
686
|
-
print: (
|
|
1132
|
+
print: ({ dirs }, { ref, dir }) => done(`created ${ref} ${inData(dirs, dir, TASK_FILE)}`)
|
|
687
1133
|
}),
|
|
688
1134
|
"new decision": write({
|
|
689
1135
|
op: "decision.new",
|
|
@@ -696,7 +1142,7 @@ const WRITES = {
|
|
|
696
1142
|
basis: opts.basis,
|
|
697
1143
|
affects: opts.affects ? list(opts.affects) : void 0
|
|
698
1144
|
}),
|
|
699
|
-
print: (
|
|
1145
|
+
print: ({ dirs }, { ref, file }) => done(`created ${ref} ${inData(dirs, file)}`)
|
|
700
1146
|
}),
|
|
701
1147
|
"new issue": write({
|
|
702
1148
|
check: checkGroupOption,
|
|
@@ -709,7 +1155,7 @@ const WRITES = {
|
|
|
709
1155
|
group: opts.group,
|
|
710
1156
|
priority: opts.priority
|
|
711
1157
|
}),
|
|
712
|
-
print: (
|
|
1158
|
+
print: ({ dirs }, { id, file }) => done(`created ${id} ${inData(dirs, file)}`)
|
|
713
1159
|
}),
|
|
714
1160
|
"new group": write({
|
|
715
1161
|
op: "group.new",
|
|
@@ -721,7 +1167,7 @@ const WRITES = {
|
|
|
721
1167
|
origin: opts.origin,
|
|
722
1168
|
labels: opts.labels ? list(opts.labels) : void 0
|
|
723
1169
|
}),
|
|
724
|
-
print: (
|
|
1170
|
+
print: ({ dirs }, { id, file }) => done(`created ${id} ${inData(dirs, file)}`)
|
|
725
1171
|
}),
|
|
726
1172
|
"group set": write({
|
|
727
1173
|
op: "group.set",
|
|
@@ -744,7 +1190,7 @@ const WRITES = {
|
|
|
744
1190
|
origin: opts.origin,
|
|
745
1191
|
labels: opts.labels ? list(opts.labels) : void 0
|
|
746
1192
|
}),
|
|
747
|
-
print: (
|
|
1193
|
+
print: ({ dirs }, { id, file }) => done(`created ${id} ${inData(dirs, file)}`)
|
|
748
1194
|
}),
|
|
749
1195
|
"mainline set": write({
|
|
750
1196
|
check: checkGroupOption,
|
|
@@ -973,7 +1419,7 @@ const WRITES = {
|
|
|
973
1419
|
section: args[1],
|
|
974
1420
|
text: args[2]
|
|
975
1421
|
}),
|
|
976
|
-
print: ({ args }, { file }) => done(`appended to ${args[0]} ## ${args[1].trim()} ${inData(file)}`)
|
|
1422
|
+
print: ({ dirs, args }, { file }) => done(`appended to ${args[0]} ## ${args[1].trim()} ${inData(dirs, file)}`)
|
|
977
1423
|
}),
|
|
978
1424
|
"decision supersede": write({
|
|
979
1425
|
op: "decision.supersede",
|
|
@@ -985,6 +1431,15 @@ const WRITES = {
|
|
|
985
1431
|
})
|
|
986
1432
|
};
|
|
987
1433
|
/**
|
|
1434
|
+
* Commands outside WRITES that change the hub's files without the writer, with whether this run does (a dry run only
|
|
1435
|
+
* previews). The writer checks the mainflow version for WRITES (requireWritableVersion); run() checks these, and warns
|
|
1436
|
+
* about every other command, which only reads.
|
|
1437
|
+
*/
|
|
1438
|
+
const ALSO_WRITING = {
|
|
1439
|
+
migrate: (opts) => !opts["dry-run"],
|
|
1440
|
+
"sync restore": (opts) => !opts["dry-run"]
|
|
1441
|
+
};
|
|
1442
|
+
/**
|
|
988
1443
|
* Run a writing command. Locally, the operation runs on the hub's files under the write lock. In remote mode it is
|
|
989
1444
|
* applied here and put in the hub's outbox, and the command waits for the sync server's answer, which may give a
|
|
990
1445
|
* new entry another id; without an answer in time it ends with the write still in the outbox.
|
|
@@ -992,50 +1447,140 @@ const WRITES = {
|
|
|
992
1447
|
function runWrite(ctx, spec) {
|
|
993
1448
|
spec.check?.(ctx);
|
|
994
1449
|
const input = spec.input(ctx);
|
|
995
|
-
if (!remoteMode(ctx.
|
|
996
|
-
|
|
997
|
-
const
|
|
1450
|
+
if (!remoteMode(ctx.dirs)) {
|
|
1451
|
+
requireWritableVersion(ctx.dirs, CLI_VERSION);
|
|
1452
|
+
const code = spec.print(ctx, applyOperation(nodeStorage(ctx.dirs), spec.op, input, { now: nowMinute() }).value);
|
|
1453
|
+
const earlier = earlierToken(ctx.dirs);
|
|
998
1454
|
if (earlier) console.error(`! ${earlier}`);
|
|
999
1455
|
return code;
|
|
1000
1456
|
}
|
|
1001
|
-
return hubWriter(ctx.
|
|
1457
|
+
return hubWriter(ctx.dirs, process.env, { confirmMs: CONFIRM_MS })(spec.op, input).then((written) => {
|
|
1002
1458
|
const code = spec.print(ctx, written.value);
|
|
1003
1459
|
for (const line of writtenLines(written)) (line.startsWith("!") ? console.error : console.log)(line);
|
|
1004
1460
|
return code;
|
|
1005
1461
|
});
|
|
1006
1462
|
}
|
|
1463
|
+
/**
|
|
1464
|
+
* The lines describing a registered hub: its name and repository, its data directory, then each repository it is the
|
|
1465
|
+
* primary hub of or references.
|
|
1466
|
+
*/
|
|
1467
|
+
function hubLines(name, hub) {
|
|
1468
|
+
const rows = [
|
|
1469
|
+
[
|
|
1470
|
+
"data",
|
|
1471
|
+
"",
|
|
1472
|
+
hub.data
|
|
1473
|
+
],
|
|
1474
|
+
...Object.entries(hub.repos).map(([repo, path]) => [
|
|
1475
|
+
"primary",
|
|
1476
|
+
repo,
|
|
1477
|
+
path
|
|
1478
|
+
]),
|
|
1479
|
+
...Object.entries(hub.references).map(([repo, path]) => [
|
|
1480
|
+
"reference",
|
|
1481
|
+
repo,
|
|
1482
|
+
path
|
|
1483
|
+
])
|
|
1484
|
+
];
|
|
1485
|
+
const width = Math.max(0, ...rows.map(([, repo]) => repo.length));
|
|
1486
|
+
return [`${name} ${hub.root}`, ...rows.map(([role, repo, path]) => ` ${role.padEnd(9)} ${repo.padEnd(width)} ${path}`)];
|
|
1487
|
+
}
|
|
1488
|
+
/** Register the hub whose repository is `path` and print what the registry now holds for it. */
|
|
1489
|
+
function register(name, path, options = {}) {
|
|
1490
|
+
const r = registerHub(name, path, process.env, options);
|
|
1491
|
+
const moved = r.previous && r.previous.root !== r.entry.root ? ` (was ${r.previous.root})` : "";
|
|
1492
|
+
const renamed = r.renamedFrom ? ` (registered as "${r.renamedFrom}" before)` : "";
|
|
1493
|
+
console.log(`registered hub ${name} in ${registryFile()}${moved}${renamed}`);
|
|
1494
|
+
for (const line of hubLines(name, r.entry)) console.log(line);
|
|
1495
|
+
for (const repo of r.missing) console.error(`! repo ${repo} is not on this machine; it belongs to ${name} once it is`);
|
|
1496
|
+
if (r.pending) console.error(`! hub ${name} has no data on this machine yet; set ${ENV.clientId} and ${ENV.clientSecret}, then run mainflow sync to download it from the sync server. Its repositories are matched once config.yaml is downloaded`);
|
|
1497
|
+
return 0;
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Why the data in `dirs` cannot move now, or null: a sync client or the hub's interface running there keeps reading
|
|
1501
|
+
* and writing the old place, so it is stopped first.
|
|
1502
|
+
*/
|
|
1503
|
+
async function busyWith(dirs, name) {
|
|
1504
|
+
const daemon = await probeDaemon(dirs);
|
|
1505
|
+
if (daemon && daemon.state !== "gone") return `mainflow sync runs for hub "${name}" (pid ${daemon.daemon.pid}); stop it first, then run this again`;
|
|
1506
|
+
const record = readUiRecord(dirs);
|
|
1507
|
+
const server = record ? await networkProbe.serverAt(record.port) : null;
|
|
1508
|
+
if (server && server.hub === name && (server.root === void 0 || server.root === dirs.root)) return `the interface of hub "${name}" runs on localhost:${record.port}${server.pid ? ` (pid ${server.pid})` : ""}; stop it first, then run this again`;
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
/** Move the data of the hub whose repository is `path` out of it, as `mainflow hub migrate-data` does, and say what happened. */
|
|
1512
|
+
async function migrateData(path, dryRun) {
|
|
1513
|
+
const move = await migrateHubData(path, {
|
|
1514
|
+
dryRun,
|
|
1515
|
+
busy: busyWith
|
|
1516
|
+
});
|
|
1517
|
+
const size = `${move.files} ${move.files === 1 ? "file" : "files"}, ${move.bytes} bytes`;
|
|
1518
|
+
const would = (done, plan) => dryRun ? plan : done;
|
|
1519
|
+
console.log(`${would("moved", "would move")} the data of hub ${move.name}: ${move.from} → ${move.to} (${size})`);
|
|
1520
|
+
console.log(`${would("registered", "would register")} hub ${move.name} in ${registryFile()} with its data in ${move.to}`);
|
|
1521
|
+
if (move.notRenamed) console.error(`! could not rename ${move.from} (${move.notRenamed}); it stays where it is and the hub no longer reads it: rename or delete it yourself`);
|
|
1522
|
+
else console.log(dryRun ? `would rename ${move.from} to ${move.kept}` : `kept the original as ${move.kept}; delete it once the hub works from ${move.to}`);
|
|
1523
|
+
if (move.worktree) {
|
|
1524
|
+
console.error(`! ${move.from} ${would("was", "is")} a git worktree; its .git file ${would("was", "would be")} left out and git ${would("was", "is")} not run. Once you no longer need the worktree, remove it from git with:`);
|
|
1525
|
+
console.error(` git -C ${move.root} worktree remove ${move.from}`);
|
|
1526
|
+
}
|
|
1527
|
+
return 0;
|
|
1528
|
+
}
|
|
1529
|
+
const warnLine = (message) => console.error(`! ${message}`);
|
|
1007
1530
|
const RUNS = {
|
|
1008
|
-
init: ({
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
const
|
|
1013
|
-
const
|
|
1531
|
+
init: ({ cwd, args }) => {
|
|
1532
|
+
const name = args[0];
|
|
1533
|
+
const registered = readRegistry().hubs[name];
|
|
1534
|
+
if (registered) throw new WriteError(`hub "${name}" is already registered, from ${registered.root} with its data in ${registered.data}; pick another name, or run mainflow hub unregister ${name} first`);
|
|
1535
|
+
const data = userDataDir(name);
|
|
1536
|
+
const created = initHub({
|
|
1537
|
+
root: realPath(cwd) ?? cwd,
|
|
1538
|
+
data
|
|
1539
|
+
}, name);
|
|
1540
|
+
console.log(`created ${realPath(created) ?? created}`);
|
|
1541
|
+
try {
|
|
1542
|
+
return register(name, cwd, { data });
|
|
1543
|
+
} catch (error) {
|
|
1544
|
+
rmSync(data, {
|
|
1545
|
+
recursive: true,
|
|
1546
|
+
force: true
|
|
1547
|
+
});
|
|
1548
|
+
throw error;
|
|
1549
|
+
}
|
|
1550
|
+
},
|
|
1551
|
+
"hub register": ({ cwd, args }) => register(args[0], resolve(cwd, args[1])),
|
|
1552
|
+
"hub migrate-data": ({ cwd, args, opts }) => migrateData(resolve(cwd, args[0]), opts["dry-run"] ?? false),
|
|
1553
|
+
"hub list": () => {
|
|
1554
|
+
const { hubs } = listHubs({ warn: warnLine });
|
|
1555
|
+
for (const [name, hub] of Object.entries(hubs)) for (const line of hubLines(name, hub)) console.log(line);
|
|
1556
|
+
const count = Object.keys(hubs).length;
|
|
1557
|
+
return done(`${count} ${count === 1 ? "hub" : "hubs"} registered in ${registryFile()}`);
|
|
1558
|
+
},
|
|
1559
|
+
"hub unregister": ({ args }) => done(`unregistered hub ${args[0]} (${unregisterHub(args[0]).root})`),
|
|
1560
|
+
status: ({ dirs, opts }) => printStatus(loadHub(dirs), opts.all ?? false),
|
|
1561
|
+
check: ({ dirs }) => {
|
|
1562
|
+
const hub = loadHub(dirs);
|
|
1563
|
+
const problems = [...hub.problems, ...loadSessions(dirs).problems];
|
|
1564
|
+
const oversized = oversizedFiles(dirs).map(({ path, bytes }) => ({
|
|
1014
1565
|
file: `${DATA_DIR}/${path}`,
|
|
1015
1566
|
field: "",
|
|
1016
|
-
message: `${bytes} bytes, more than the ${MAX_FILE_BYTES} the sync server keeps of a file, so mainflow sync leaves it out and other environments do not see it; split it, or
|
|
1567
|
+
message: `${bytes} bytes, more than the ${MAX_FILE_BYTES} the sync server keeps of a file, so mainflow sync leaves it out and other environments do not see it; split it, or move it into a scratch/ directory, which is never synced`
|
|
1017
1568
|
}));
|
|
1018
1569
|
const warnings = [...hub.warnings, ...oversized];
|
|
1019
|
-
printProblems(warnings, "!");
|
|
1570
|
+
printProblems(warnings, "!", dirs);
|
|
1020
1571
|
if (problems.length === 0) return done(`✓ ${hub.mainlines.length} mainlines, no problems${warnings.length ? `, ${warnings.length} ${warnings.length === 1 ? "warning" : "warnings"}` : ""}`);
|
|
1021
|
-
printProblems(problems);
|
|
1572
|
+
printProblems(problems, "✗", dirs);
|
|
1022
1573
|
return 1;
|
|
1023
1574
|
},
|
|
1024
|
-
migrate: ({
|
|
1575
|
+
migrate: ({ dirs, opts }) => {
|
|
1025
1576
|
const dryRun = opts["dry-run"] ?? false;
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
for (const file of report.written) console.log(` ${dryRun ? "would write" : "wrote"} ${file}`);
|
|
1029
|
-
for (const file of report.removed) console.log(` ${dryRun ? "would remove" : "removed"} ${file}`);
|
|
1030
|
-
if (report.missingDefinitions.length) {
|
|
1031
|
-
console.log(`${report.missingDefinitions.length} open entries still need a definition of done; fill them in the files:`);
|
|
1032
|
-
for (const entry of report.missingDefinitions) console.log(` ${entry}`);
|
|
1033
|
-
}
|
|
1577
|
+
if (remoteMode(dirs)) return syncMigrate(dirs, { dryRun });
|
|
1578
|
+
for (const line of migrationLines(migrateHub(dirs, { dryRun }), dryRun)) console.log(line);
|
|
1034
1579
|
return 0;
|
|
1035
1580
|
},
|
|
1036
|
-
ui: ({
|
|
1037
|
-
const config = loadHub(
|
|
1038
|
-
launchUi(
|
|
1581
|
+
ui: ({ dirs, opts }) => {
|
|
1582
|
+
const config = loadHub(dirs).config;
|
|
1583
|
+
launchUi(dirs, config.hub, config.ui?.port, {
|
|
1039
1584
|
port: opts.port,
|
|
1040
1585
|
open: !opts["no-open"]
|
|
1041
1586
|
}).then((result) => {
|
|
@@ -1046,13 +1591,13 @@ const RUNS = {
|
|
|
1046
1591
|
});
|
|
1047
1592
|
return 0;
|
|
1048
1593
|
},
|
|
1049
|
-
"session show": ({
|
|
1594
|
+
"session show": ({ dirs, args, opts }) => {
|
|
1050
1595
|
const id = sessionOf(opts, args[0]);
|
|
1051
|
-
const session = readSession(
|
|
1596
|
+
const session = readSession(dirs, id);
|
|
1052
1597
|
if (!session) throw new WriteError(`session ${id} not found; start one with "mainflow session start"`);
|
|
1053
|
-
return done(sessionBriefing(
|
|
1598
|
+
return done(sessionBriefing(dirs, session, { cli: CLI }));
|
|
1054
1599
|
},
|
|
1055
|
-
wait: ({
|
|
1600
|
+
wait: ({ dirs, args, opts }) => waitUntil(dirs, args[0], opts),
|
|
1056
1601
|
hook: ({ args }) => {
|
|
1057
1602
|
let input = "";
|
|
1058
1603
|
try {
|
|
@@ -1080,8 +1625,8 @@ const RUNS = {
|
|
|
1080
1625
|
return 0;
|
|
1081
1626
|
});
|
|
1082
1627
|
},
|
|
1083
|
-
"task show": ({
|
|
1084
|
-
const { mainline, task } = findTask(loadHub(
|
|
1628
|
+
"task show": ({ dirs, args }) => {
|
|
1629
|
+
const { mainline, task } = findTask(loadHub(dirs), args[0]);
|
|
1085
1630
|
console.log(`${mainline.id}/${task.id} ${task.status.padEnd(8)} ${task.kind} ${task.title}`);
|
|
1086
1631
|
if (task.checklist.length === 0) console.log(" (no checklist items)");
|
|
1087
1632
|
for (const c of task.checklist) {
|
|
@@ -1092,42 +1637,52 @@ const RUNS = {
|
|
|
1092
1637
|
for (const link of task.links ?? []) console.log(` ${link.rel} → ${link.to}${link.note ? ` ${link.note}` : ""}`);
|
|
1093
1638
|
return done(`${task.checklist.length} checklist items`);
|
|
1094
1639
|
},
|
|
1095
|
-
"issue list": ({
|
|
1096
|
-
const shown = loadHub(
|
|
1640
|
+
"issue list": ({ dirs, opts }) => {
|
|
1641
|
+
const shown = loadHub(dirs).issues.filter((i) => opts.all || i.status !== "closed");
|
|
1097
1642
|
for (const i of shown) console.log(` ${i.id} ${i.status.padEnd(7)} ${i.title}${i.plannedIn ? ` → ${i.plannedIn}` : ""}${i.origin ? ` (from ${i.origin})` : ""}${i.group ? ` [${i.group}]` : ""}`);
|
|
1098
1643
|
return done(`${shown.length} issues`);
|
|
1099
1644
|
},
|
|
1100
|
-
"group list": ({
|
|
1101
|
-
"group show": ({
|
|
1102
|
-
"finding list": ({
|
|
1103
|
-
const shown = loadHub(
|
|
1645
|
+
"group list": ({ dirs, opts }) => printGroups(loadHub(dirs), opts.all ?? false),
|
|
1646
|
+
"group show": ({ dirs, args }) => printGroup(loadHub(dirs), loadSessions(dirs).sessions, args[0]),
|
|
1647
|
+
"finding list": ({ dirs, opts }) => {
|
|
1648
|
+
const shown = loadHub(dirs).findings.filter((f) => opts.all || f.status === "valid");
|
|
1104
1649
|
for (const f of shown) console.log(` ${f.id} ${f.created} ${f.title}${f.labels?.length ? ` [${f.labels.join(", ")}]` : ""}`);
|
|
1105
1650
|
return done(`${shown.length} findings`);
|
|
1106
1651
|
},
|
|
1107
|
-
"ops list": ({
|
|
1652
|
+
"ops list": ({ dirs, opts }) => {
|
|
1108
1653
|
const covers = (ref) => !opts.ref || ref !== void 0 && (ref === opts.ref || ref.startsWith(`${opts.ref}/`) || ref.startsWith(`${opts.ref}#`));
|
|
1109
1654
|
const local = (at) => {
|
|
1110
1655
|
const d = new Date(stampMs(at));
|
|
1111
1656
|
return `${d.toLocaleDateString("sv-SE")}T${d.toTimeString().slice(0, 5)}`;
|
|
1112
1657
|
};
|
|
1113
|
-
const shown = loadHub(
|
|
1658
|
+
const shown = loadHub(dirs).ops.filter((o) => (!opts.month || local(o.at).startsWith(opts.month)) && covers(o.ref));
|
|
1114
1659
|
for (const o of shown) console.log(` ${local(o.at)} ${o.target} ${o.operation} ${o.result}${o.ref ? ` ${o.ref}` : ""}`);
|
|
1115
1660
|
return done(`${shown.length} operations`);
|
|
1116
1661
|
},
|
|
1117
|
-
retro: ({
|
|
1662
|
+
retro: ({ dirs, args, opts }) => {
|
|
1118
1663
|
const print = (report) => done(opts.json ? JSON.stringify(report, null, 2) : retroText(report));
|
|
1119
1664
|
const receiver = receiverFromEnv(process.env);
|
|
1120
|
-
return receiver ? retroReportFromReceiver(
|
|
1665
|
+
return receiver ? retroReportFromReceiver(dirs, args[0], receiver).then(print) : print(retroReport(dirs, args[0]));
|
|
1121
1666
|
},
|
|
1122
|
-
"decision list": ({
|
|
1123
|
-
const mainline = findMainline(loadHub(
|
|
1667
|
+
"decision list": ({ dirs, args }) => {
|
|
1668
|
+
const mainline = findMainline(loadHub(dirs), args[0]);
|
|
1124
1669
|
for (const d of mainline.decisions) console.log(` ${mainline.id}/${d.id} ${d.status.padEnd(10)} ${d.title}`);
|
|
1125
1670
|
return done(`${mainline.decisions.length} decisions`);
|
|
1126
1671
|
},
|
|
1127
|
-
sync: ({
|
|
1128
|
-
"sync flush": (
|
|
1129
|
-
"sync fetch": (
|
|
1130
|
-
"sync status": ({
|
|
1672
|
+
sync: ({ dirs }) => syncRun(dirs),
|
|
1673
|
+
"sync flush": (ctx) => ctx.opts.hook ? syncFlushHook(ctx.cwd) : syncFlush(ctx.dirs),
|
|
1674
|
+
"sync fetch": (ctx) => ctx.opts.hook ? syncFetchHook(ctx.cwd) : syncFetch(ctx.dirs, ctx.args[0]),
|
|
1675
|
+
"sync status": ({ dirs }) => syncStatus(dirs),
|
|
1676
|
+
"sync log": ({ dirs, args, opts }) => syncLog(dirs, args[0], {
|
|
1677
|
+
limit: opts.limit,
|
|
1678
|
+
json: opts.json
|
|
1679
|
+
}),
|
|
1680
|
+
"sync restore": ({ dirs, args, opts }) => syncRestore(dirs, args[0], {
|
|
1681
|
+
seq: opts.seq,
|
|
1682
|
+
at: opts.at,
|
|
1683
|
+
dryRun: opts["dry-run"],
|
|
1684
|
+
json: opts.json
|
|
1685
|
+
})
|
|
1131
1686
|
};
|
|
1132
1687
|
const COMMANDS = Object.fromEntries(Object.keys(COMMAND_SPECS).map((name) => [name, {
|
|
1133
1688
|
...COMMAND_SPECS[name],
|
|
@@ -1183,23 +1738,61 @@ function run(argv, cwd) {
|
|
|
1183
1738
|
if (!command) return fail(`Unknown command "${positionals.join(" ")}".\n\n${USAGE}`);
|
|
1184
1739
|
const args = positionals.slice(key.split(" ").length);
|
|
1185
1740
|
if (args.length < command.args || args.length > command.args + (command.optionalArgs ?? 0)) return fail(`Usage: mainflow ${command.usage}`);
|
|
1186
|
-
let
|
|
1187
|
-
if (command.needsHub) {
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1741
|
+
let hub = null;
|
|
1742
|
+
if (command.needsHub && !(opts.hook && HOOK_FINDS_HUB.has(key))) {
|
|
1743
|
+
try {
|
|
1744
|
+
hub = resolveHub(cwd, {
|
|
1745
|
+
warn: warnLine,
|
|
1746
|
+
waitForLock: !opts.hook
|
|
1747
|
+
});
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
return reportError(error);
|
|
1750
|
+
}
|
|
1751
|
+
if (!hub) return fail(notRegisteredMessage(cwd));
|
|
1752
|
+
if (!existsSync(join(hub.data, "config.yaml")) && !NEEDS_NO_CONFIG.has(key)) return fail(`✗ hub "${registeredName(hub) ?? "(unnamed)"}" has no config.yaml in ${hub.data} yet; set ${ENV.clientId} and ${ENV.clientSecret}, then run mainflow sync to download the hub from the sync server`);
|
|
1753
|
+
}
|
|
1754
|
+
const hookHub = () => {
|
|
1755
|
+
try {
|
|
1756
|
+
return resolveHub(cwd, { waitForLock: false });
|
|
1757
|
+
} catch {
|
|
1758
|
+
return null;
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
const gated = command.needsHub ? hub : key === "hook" ? hookHub() : null;
|
|
1762
|
+
if (gated && !(key in WRITES)) {
|
|
1763
|
+
const verdict = versionGate(gated, CLI_VERSION, { writes: ALSO_WRITING[key]?.(opts) ?? false });
|
|
1764
|
+
if (verdict.kind === "refuse") return fail(`✗ refusing to write: ${verdict.message}`);
|
|
1765
|
+
if (verdict.kind === "warn") console.error(`! ${verdict.message}`);
|
|
1191
1766
|
}
|
|
1192
1767
|
try {
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1768
|
+
const context = {
|
|
1769
|
+
cwd,
|
|
1770
|
+
get dirs() {
|
|
1771
|
+
if (!hub) throw new Error(`mainflow ${key} runs outside a hub and has no hub directories`);
|
|
1772
|
+
return hub;
|
|
1773
|
+
},
|
|
1195
1774
|
args,
|
|
1196
1775
|
opts
|
|
1197
|
-
}
|
|
1776
|
+
};
|
|
1777
|
+
const exit = command.run(context);
|
|
1198
1778
|
return typeof exit === "number" ? exit : exit.catch(reportError);
|
|
1199
1779
|
} catch (error) {
|
|
1200
1780
|
return reportError(error);
|
|
1201
1781
|
}
|
|
1202
1782
|
}
|
|
1783
|
+
/** Commands that run in a registered hub whose config.yaml is not downloaded yet: they are how it gets downloaded, or read the server alone. */
|
|
1784
|
+
const NEEDS_NO_CONFIG = /* @__PURE__ */ new Set([
|
|
1785
|
+
"sync",
|
|
1786
|
+
"sync status",
|
|
1787
|
+
"sync log",
|
|
1788
|
+
"sync restore"
|
|
1789
|
+
]);
|
|
1790
|
+
/**
|
|
1791
|
+
* Commands that, run with --hook, find their hub from the hook's payload rather than the current directory, and answer
|
|
1792
|
+
* nothing outside a registered hub: the forwarding script hands them calls without deciding which hub they belong to
|
|
1793
|
+
* (docs/hooks.md 轉發腳本).
|
|
1794
|
+
*/
|
|
1795
|
+
const HOOK_FINDS_HUB = /* @__PURE__ */ new Set(["sync flush", "sync fetch"]);
|
|
1203
1796
|
/** A refused write or an unreadable hub ends the command with its message; anything else is a bug and is thrown. */
|
|
1204
1797
|
function reportError(error) {
|
|
1205
1798
|
if (error instanceof HubError) {
|
|
@@ -1228,9 +1821,9 @@ function sessionOf(opts, arg) {
|
|
|
1228
1821
|
/** An action named as a{NNN} in the current session, or as {session}#a{NNN}. */
|
|
1229
1822
|
const actionRef = (opts, arg) => /^a\d{3}$/.test(arg) ? `${sessionOf(opts)}#${arg}` : arg;
|
|
1230
1823
|
/** Print a line for each running session already doing what this action serves; a reminder, so the exit code stays 0. */
|
|
1231
|
-
function remindDuplicates(
|
|
1824
|
+
function remindDuplicates(dirs, ref) {
|
|
1232
1825
|
const hash = ref.lastIndexOf("#");
|
|
1233
|
-
for (const line of duplicateClaimLines(
|
|
1826
|
+
for (const line of duplicateClaimLines(dirs, ref.slice(0, hash), ref.slice(hash + 1))) console.log(line);
|
|
1234
1827
|
return 0;
|
|
1235
1828
|
}
|
|
1236
1829
|
/** Exit codes of `wait`: the condition holds, the time ran out, or there is no condition to wait for. */
|
|
@@ -1257,7 +1850,7 @@ function seconds(value, fallback, name, zero) {
|
|
|
1257
1850
|
* round, so a condition changed while waiting is the one checked; once the action leaves blocked or loses its
|
|
1258
1851
|
* `until`, there is nothing left to wait for. A condition that can no longer come true ends the wait at once.
|
|
1259
1852
|
*/
|
|
1260
|
-
function waitUntil(
|
|
1853
|
+
function waitUntil(dirs, arg, opts) {
|
|
1261
1854
|
let timeout;
|
|
1262
1855
|
let interval;
|
|
1263
1856
|
let ref;
|
|
@@ -1272,7 +1865,7 @@ function waitUntil(root, arg, opts) {
|
|
|
1272
1865
|
const hash = actionRef.lastIndexOf("#");
|
|
1273
1866
|
let session;
|
|
1274
1867
|
try {
|
|
1275
|
-
session = readSession(
|
|
1868
|
+
session = readSession(dirs, actionRef.slice(0, hash));
|
|
1276
1869
|
} catch (error) {
|
|
1277
1870
|
return { error: error.message };
|
|
1278
1871
|
}
|
|
@@ -1293,7 +1886,7 @@ function waitUntil(root, arg, opts) {
|
|
|
1293
1886
|
text = until;
|
|
1294
1887
|
} else text = opts.until;
|
|
1295
1888
|
const parsed = parseUntil(text);
|
|
1296
|
-
if (!parsed) return unusable(`"${text}" is not a condition
|
|
1889
|
+
if (!parsed) return unusable(`"${text}" is not a condition: ${untilProblem(text)}`);
|
|
1297
1890
|
condition = parsed;
|
|
1298
1891
|
} catch (error) {
|
|
1299
1892
|
if (error instanceof WriteError) return unusable(error.message);
|
|
@@ -1301,7 +1894,7 @@ function waitUntil(root, arg, opts) {
|
|
|
1301
1894
|
}
|
|
1302
1895
|
const deadline = Date.now() + timeout * 1e3;
|
|
1303
1896
|
for (;;) {
|
|
1304
|
-
const state = untilState(
|
|
1897
|
+
const state = untilState(dirs, condition);
|
|
1305
1898
|
if (state.met) return done(`✓ ${text}: ${state.detail}`);
|
|
1306
1899
|
if (state.unreachable) return unusable(`${text} can no longer hold: ${state.detail}`);
|
|
1307
1900
|
const left = deadline - Date.now();
|
|
@@ -1407,8 +2000,15 @@ function printGroup(hub, sessions, idInput) {
|
|
|
1407
2000
|
if (plan.active.length) console.log(` 服務組條件的行動:${work(plan.active)}`);
|
|
1408
2001
|
return done(`${plan.members.length} members`);
|
|
1409
2002
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
2003
|
+
/**
|
|
2004
|
+
* Print problems one a line. Given the hub's directories, each file is printed as its absolute path, which the reader
|
|
2005
|
+
* opens as it is; otherwise as the problem names it, `.mainflow/{path}`.
|
|
2006
|
+
*/
|
|
2007
|
+
function printProblems(problems, mark = "✗", dirs) {
|
|
2008
|
+
for (const problem of problems) {
|
|
2009
|
+
const file = dirs ? dataFile(dirs, problem.file) : problem.file;
|
|
2010
|
+
console.error(`${mark} ${file}${problem.field ? ` › ${problem.field}` : ""}: ${problem.message}`);
|
|
2011
|
+
}
|
|
1412
2012
|
}
|
|
1413
2013
|
const entry = process.argv[1];
|
|
1414
2014
|
if (entry && realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) {
|