@xinhe-oa/cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ // 登录、凭据生命周期与续期(契约 §4)。
2
+ //
3
+ // 四条纪律,全部在这里收口:
4
+ //
5
+ // 1. **令牌永不进 argv、永不进日志、永不进错误详情**。凭据只从
6
+ // `~/.config/xinhe-oa-cli/credentials.json`(0600)读;
7
+ // `login --password` 的口令从 **stdin** 读(不做 `--password <明文>`)。
8
+ // 2. **设备指纹首次登录生成并持久化**,每个请求带 `X-Device-Id` +
9
+ // 固定 UA(`xinhe-oa-cli/1`)。换设备 → 服务端 `auth.device_mismatch`。
10
+ // 3. **续期**:access 默认 600s,提前 60s 用 refresh 换新的;
11
+ // refresh **一次性轮换**,新值**立即落盘**(换完就把旧的扔掉 = 自己把自己踢下线)。
12
+ // 4. **换不回来 → 退出码 3 + hint 给出短信登录命令**(契约 §3.2 / §4)。
13
+
14
+ import { CliError, Kind, loginHint } from './error.js';
15
+ import {
16
+ ProxyMode,
17
+ hasAccess,
18
+ hasRefresh,
19
+ toCredentialJson,
20
+ } from './config.js';
21
+ import { Http, Auth, requestSpec } from './http.js';
22
+ import { nowRfc3339, parseRfc3339 } from './util.js';
23
+ import { verbose } from './output.js';
24
+
25
+ /** 续期提前量(契约 §4:access 默认 600s,提前 60s 换)。 */
26
+ export const REFRESH_MARGIN_SECS = 60;
27
+ /** 续期请求允许的重试次数(轮换是"读改写",重试要谨慎)。 */
28
+ const REFRESH_ATTEMPTS = 2;
29
+
30
+ /** 一次 API 调用所需的认证上下文。 */
31
+ export class AuthContext {
32
+ constructor({ profile, http, credential, verbose: isVerbose = false }) {
33
+ this.profile = profile;
34
+ this.http = http;
35
+ this.credential = credential;
36
+ this.verbose = isVerbose;
37
+ }
38
+ }
39
+
40
+ function serverShapeError(what) {
41
+ return new CliError(
42
+ Kind.Server,
43
+ 'cli.bad_response',
44
+ `服务端响应形状不对:${what}`,
45
+ ).withHint('这多半是服务端版本不匹配:跑 `xinhe-oa-cli doctor` 看两侧版本');
46
+ }
47
+
48
+ /** 服务端 `data.tokens` + `data.user` → 本地凭据。 */
49
+ export function credentialFromTokens(body, baseUrl, deviceId, phone) {
50
+ const tokens = body?.tokens;
51
+ if (tokens === undefined || tokens === null) throw serverShapeError('响应里没有 data.tokens');
52
+ if (typeof tokens.access_token !== 'string') {
53
+ throw serverShapeError('响应里没有 tokens.access_token');
54
+ }
55
+ const credential = {
56
+ base_url: baseUrl,
57
+ device_id: deviceId,
58
+ tokens: {
59
+ access_token: tokens.access_token,
60
+ refresh_token: typeof tokens.refresh_token === 'string' ? tokens.refresh_token : '',
61
+ },
62
+ };
63
+ if (typeof tokens.access_expires_at === 'string') {
64
+ credential.tokens.access_expires_at = tokens.access_expires_at;
65
+ }
66
+ if (typeof tokens.refresh_expires_at === 'string') {
67
+ credential.tokens.refresh_expires_at = tokens.refresh_expires_at;
68
+ }
69
+ if (body?.user !== undefined && body?.user !== null) credential.user = body.user;
70
+ if (phone !== null && phone !== undefined) credential.phone = phone;
71
+ credential.updated_at = nowRfc3339();
72
+ return credential;
73
+ }
74
+
75
+ /**
76
+ * 从 stdin 读一行(口令用)。**不回显、不落历史、不进日志**。
77
+ *
78
+ * 读**整个** stdin 再取第一行 —— 与 Rust 版一致:`printf '%s' "$PW" | …`
79
+ * 没有换行结尾时,按行读会拿到空串。
80
+ */
81
+ export async function readSecretFromStdin(prompt) {
82
+ process.stderr.write(prompt);
83
+ const chunks = [];
84
+ for await (const chunk of process.stdin) chunks.push(chunk);
85
+ const text = Buffer.concat(chunks).toString('utf8');
86
+ const secret = (text.split('\n')[0] ?? '').trim();
87
+ if (secret.length === 0) {
88
+ throw CliError.usage('标准输入里没有口令').withHint(
89
+ "用法:printf '%s' \"$XINHE_OA_PASSWORD\" | xinhe-oa-cli login --password --account <账号>",
90
+ );
91
+ }
92
+ return secret;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // 令牌到期判断
97
+ // ---------------------------------------------------------------------------
98
+ /**
99
+ * access 是否已经过期或将在 `REFRESH_MARGIN_SECS` 内过期。
100
+ *
101
+ * **缺时间戳时返回 true**(宁可多刷一次,也不要拿一枚可能已经过期的令牌去撞 401:
102
+ * 服务端对过期令牌回 `auth.token_expired`,而它属于退出码 3 —— 那会让一次
103
+ * "其实能自动续上的"调用变成"请重新登录")。
104
+ */
105
+ export function accessNeedsRefresh(credential) {
106
+ const raw = credential?.tokens?.access_expires_at;
107
+ if (typeof raw !== 'string') return true;
108
+ const expiresAt = parseRfc3339(raw);
109
+ if (expiresAt === null) return true;
110
+ return expiresAt - Date.now() <= REFRESH_MARGIN_SECS * 1000;
111
+ }
112
+
113
+ /** refresh 是否已经过期(过期就别白跑一趟网络)。缺时间戳 = 不知道 = 不拦。 */
114
+ export function refreshExpired(credential) {
115
+ const raw = credential?.tokens?.refresh_expires_at;
116
+ if (typeof raw !== 'string') return false;
117
+ const expiresAt = parseRfc3339(raw);
118
+ if (expiresAt === null) return false;
119
+ return expiresAt <= Date.now();
120
+ }
121
+
122
+ function expiredError(credential) {
123
+ return new CliError(
124
+ Kind.Auth,
125
+ 'auth.refresh_expired',
126
+ '登录已过期,需要重新做一次短信验证',
127
+ ).withHint(loginHint(credential?.phone ?? null));
128
+ }
129
+
130
+ /** 服务端响应统一 `{"data": ...}` 包装;这里取 `data`(没有就退回整个响应体)。 */
131
+ export function envelopeData(response) {
132
+ const body = response?.body;
133
+ if (body && typeof body === 'object' && body.data !== undefined && body.data !== null) {
134
+ return body.data;
135
+ }
136
+ return body ?? {};
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // 续期
141
+ // ---------------------------------------------------------------------------
142
+ /**
143
+ * 用 refresh 换新令牌。**新值立即落盘**(一次性轮换:不落盘就等于把这枚令牌弄丢了)。
144
+ *
145
+ * 调用方传进来的 `session` 是 `{ credential, store, profile }`:
146
+ * 凭据对象在这里被**原地更新**,落盘走 `store.put`。
147
+ */
148
+ export async function refreshTokens(http, session, isVerbose) {
149
+ const { credential, store, profile } = session;
150
+ if (!hasRefresh(credential)) throw expiredError(credential);
151
+ if (refreshExpired(credential)) throw expiredError(credential);
152
+
153
+ let lastError = null;
154
+ for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt += 1) {
155
+ const spec = requestSpec({ body: { refresh_token: credential.tokens.refresh_token } });
156
+ try {
157
+ const response = await http.send('POST', '/auth/refresh', Auth.none(), spec, '登录续期');
158
+ const body = envelopeData(response);
159
+ const fresh = credentialFromTokens(
160
+ body,
161
+ http.baseUrl,
162
+ credential.device_id,
163
+ credential.phone ?? null,
164
+ );
165
+ // 服务端可能不回 user(`POST /auth/refresh` 会回),保守起见沿用旧的。
166
+ if (fresh.user === undefined) fresh.user = credential.user;
167
+ verbose(isVerbose, 'access 已续期(refresh 已轮换并落盘)');
168
+ session.credential = fresh;
169
+ store.put(profile, fresh);
170
+ return fresh;
171
+ } catch (err) {
172
+ const retryable =
173
+ err instanceof CliError &&
174
+ ['cli.bad_response', 'internal.error', 'rate.limited'].includes(err.code) &&
175
+ attempt + 1 < REFRESH_ATTEMPTS;
176
+ if (retryable) {
177
+ process.stderr.write('· 续期响应异常,稍后重试一次\n');
178
+ await sleepMs(500);
179
+ lastError = err;
180
+ continue;
181
+ }
182
+ throw err;
183
+ }
184
+ }
185
+ throw lastError ?? expiredError(credential);
186
+ }
187
+
188
+ function sleepMs(ms) {
189
+ return new Promise((resolve) => {
190
+ const handle = setTimeout(resolve, ms);
191
+ if (typeof handle.unref === 'function') handle.unref();
192
+ });
193
+ }
194
+
195
+ /** access 需要刷新就先刷(幂等:不需要时一次网络都不发)。 */
196
+ export async function ensureFresh(session, isVerbose) {
197
+ if (!accessNeedsRefresh(session.credential)) return;
198
+ if (!hasRefresh(session.credential)) throw expiredError(session.credential);
199
+ verbose(isVerbose, 'access 临近过期,先续期');
200
+ await refreshTokens(session.http, session, isVerbose);
201
+ }
202
+
203
+ /**
204
+ * 用当前凭据发一次**已认证**请求;access 快过期就先续期,
205
+ * 拿到 `auth.token_expired` 再续一次并重试一次。
206
+ */
207
+ export async function authedSend(session, method, path, spec, command) {
208
+ await ensureFresh(session, session.verbose);
209
+ try {
210
+ return await session.http.send(
211
+ method,
212
+ path,
213
+ Auth.bearer(session.credential.tokens.access_token),
214
+ spec,
215
+ command,
216
+ );
217
+ } catch (err) {
218
+ if (!(err instanceof CliError) || err.code !== 'auth.token_expired') throw err;
219
+ // 本地时钟偏差或服务端刚吊销:续一次再试一次。
220
+ verbose(session.verbose, 'access 被服务端判为过期,续期后重试一次');
221
+ await refreshTokens(session.http, session, session.verbose);
222
+ return session.http.send(
223
+ method,
224
+ path,
225
+ Auth.bearer(session.credential.tokens.access_token),
226
+ spec,
227
+ command,
228
+ );
229
+ }
230
+ }
231
+
232
+ /** multipart 上传(附件用)。 */
233
+ export async function authedUpload(session, path, form, command, idempotencyKey) {
234
+ await ensureFresh(session, session.verbose);
235
+ const spec = requestSpec({ multipart: form, idempotencyKey: idempotencyKey ?? null });
236
+ return session.http.send(
237
+ 'POST',
238
+ path,
239
+ Auth.bearer(session.credential.tokens.access_token),
240
+ spec,
241
+ command,
242
+ );
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // 登录 / 登出
247
+ // ---------------------------------------------------------------------------
248
+ /**
249
+ * `login --phone --code`:校验并落盘凭据。
250
+ *
251
+ * 首次调用时 `deviceId` 必须是**先持久化再用**(由调用方保证):
252
+ * 服务端把 refresh 绑在 `HMAC(UA + X-Device-Id)` 上,落盘前走网络不会有问题,
253
+ * 但**下一次**续期用的是落盘的那个 id —— 不一致就 `auth.device_mismatch`。
254
+ */
255
+ export async function verifyOtp(http, phone, code, deviceId) {
256
+ const spec = requestSpec({ body: { phone, code } });
257
+ const response = await http.send('POST', '/auth/otp/verify', Auth.none(), spec, 'login --code');
258
+ return credentialFromTokens(envelopeData(response), http.baseUrl, deviceId, phone);
259
+ }
260
+
261
+ /** `login --password --account`:仅引导账号。口令从 stdin 读。 */
262
+ export async function loginWithPassword(http, account, password, deviceId) {
263
+ const spec = requestSpec({ body: { account, password } });
264
+ const response = await http.send('POST', '/auth/login', Auth.none(), spec, 'login --password');
265
+ return credentialFromTokens(envelopeData(response), http.baseUrl, deviceId, account);
266
+ }
267
+
268
+ /**
269
+ * `login --adopt-session`:从 stdin 读一个 JSON 会话交接体并落盘。
270
+ *
271
+ * 形状(由 install.sh / install.ps1 生成):
272
+ * ```jsonc
273
+ * {"base_url":"http://...","device_id":"dev-...",
274
+ * "tokens":{"access_token":"...","refresh_token":"...","access_expires_at":"RFC3339",
275
+ * "refresh_expires_at":"RFC3339"},
276
+ * "user":{"id":"usr_...","name":"..."}}
277
+ * ```
278
+ */
279
+ export function adoptSession(raw, fallbackBaseUrl) {
280
+ let parsed;
281
+ try {
282
+ parsed = JSON.parse(String(raw ?? '').trim());
283
+ } catch (err) {
284
+ throw CliError.usage(`从 stdin 读到的会话不是合法 JSON:${err.message}`).withHint(
285
+ "用法:echo '<install.sh 交接的会话 JSON>' | xinhe-oa-cli login --adopt-session",
286
+ );
287
+ }
288
+ const tokens = parsed?.tokens;
289
+ if (tokens === undefined || tokens === null) throw CliError.usage('交接体里缺 tokens');
290
+ if (typeof tokens.access_token !== 'string') {
291
+ throw CliError.usage('交接体里缺 tokens.access_token');
292
+ }
293
+ if (tokens.access_token.length === 0) {
294
+ throw CliError.usage('交接体里的 tokens.access_token 是空串');
295
+ }
296
+ // device_id 必须沿用交接方的:服务端把 refresh 绑在 HMAC(UA + X-Device-Id) 上,
297
+ // 这里换一个 id 会让第一次续期直接 `auth.device_mismatch`。
298
+ const deviceId =
299
+ typeof parsed.device_id === 'string' && parsed.device_id.trim().length > 0
300
+ ? parsed.device_id
301
+ : null;
302
+ if (deviceId === null) {
303
+ throw CliError.usage('交接体里缺 device_id').withHint(
304
+ 'install.sh 必须把它做 OTP 时用的 X-Device-Id 一起交接,否则续期会被拒',
305
+ );
306
+ }
307
+ const baseUrl =
308
+ typeof parsed.base_url === 'string' && parsed.base_url.trim().length > 0
309
+ ? parsed.base_url.trim().replace(/\/+$/, '')
310
+ : String(fallbackBaseUrl ?? '').trim().replace(/\/+$/, '');
311
+
312
+ const credential = {
313
+ base_url: baseUrl,
314
+ device_id: deviceId,
315
+ tokens: {
316
+ access_token: tokens.access_token,
317
+ refresh_token: typeof tokens.refresh_token === 'string' ? tokens.refresh_token : '',
318
+ },
319
+ };
320
+ if (typeof tokens.access_expires_at === 'string') {
321
+ credential.tokens.access_expires_at = tokens.access_expires_at;
322
+ }
323
+ if (typeof tokens.refresh_expires_at === 'string') {
324
+ credential.tokens.refresh_expires_at = tokens.refresh_expires_at;
325
+ }
326
+ if (parsed.user !== undefined && parsed.user !== null) credential.user = parsed.user;
327
+ if (typeof parsed.phone === 'string') credential.phone = parsed.phone;
328
+ credential.updated_at = nowRfc3339();
329
+ return credential;
330
+ }
331
+
332
+ /** `logout`:吊销会话链并删掉本地凭据。 */
333
+ export async function logout(http, credential) {
334
+ const body = hasRefresh(credential) ? { refresh_token: credential.tokens.refresh_token } : {};
335
+ const spec = requestSpec({ body });
336
+ // logout 常带着**已经过期**的 access 来:令牌过期不该挡登出,
337
+ // 所以这里用 refresh 当凭据、access 只是"如果能用就用"。
338
+ const auth = hasAccess(credential) ? Auth.bearer(credential.tokens.access_token) : Auth.none();
339
+ const response = await http.send('POST', '/auth/logout', auth, spec, 'logout');
340
+ return envelopeData(response);
341
+ }
342
+
343
+ /** 只给测试/工具用:把凭据的落盘形状导出(见 `toCredentialJson`)。 */
344
+ export { toCredentialJson };
345
+
346
+ /** 按 profile 建一个 Http 客户端(base_url 已经解析过)。 */
347
+ export function httpFor(baseUrl, proxyMode, deviceId, timeoutMs) {
348
+ return new Http(baseUrl, proxyMode ?? ProxyMode.System, deviceId, timeoutMs);
349
+ }
@@ -0,0 +1,226 @@
1
+ // 导入断点续传(`bitable table --import` 的 checkpoint)。
2
+ //
3
+ // 现场问题(2026-09-29):一次 5,611 行的导入被打断三次,每次都得**从头再来** ——
4
+ // 服务端逐行写 31–57 ms,白跑的就是十几分钟。而"哪些批已经成功"这件事,
5
+ // 客户端本来就知道(每批一个幂等键、每次成功都有响应),只是没落盘。
6
+ //
7
+ // 落盘内容(`<config>/checkpoints/<table_id>-<文件指纹>.json`)刻意包含
8
+ // **批的边界本身**,而不只是"已完成到第几行":
9
+ //
10
+ // ```json
11
+ // {
12
+ // "version": 1, "table_id": "...", "file_fingerprint": "...",
13
+ // "rows": 5611, "batch_size": 500, "updated_at": "...",
14
+ // "batches": [{"index":0,"start":0,"end":500,"records":500,"done":true,
15
+ // "idempotency_key":"import:...:tbl_...:0"}, ...]
16
+ // }
17
+ // ```
18
+ //
19
+ // 为什么必须存边界:服务端的幂等键是**客户端给的**,而键里带着批序号。
20
+ // 第二次运行时如果 `--batch-size` 变了,切出来的批与第一次不同 → 键不同 →
21
+ // 服务端认为是新请求 → **同一份数据被写第二遍**。所以续传必须沿用第一次的批划分
22
+ // (见 `samePlan`)。这条约束写在这里,是因为它不写下来就会被"优化"掉。
23
+ //
24
+ // 写盘用「临时文件 + rename」:导入中途断电只会留下旧文件或新文件,
25
+ // 不会留下半个 JSON(半个 JSON 会让下一次运行判定为损坏并从头再来)。
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ import { CliError } from './error.js';
31
+ import { rustIoErrorText } from './util.js';
32
+ import { sortKeysDeep } from './util.js';
33
+
34
+ /** checkpoint 的格式版本:字段含义变化时 +1(读到更高版本就当读不懂,从头来)。 */
35
+ export const VERSION = 1;
36
+
37
+ /** checkpoint 文件路径:`<config>/checkpoints/<table_id>-<file_fingerprint>.json`。
38
+ *
39
+ * 文件名里带**文件指纹**而不只是表 id:同一张表每月追加不同月份的文件时,
40
+ * 两个文件各自的进度不能互相覆盖(那会让"上个月导完了"变成"这个月不用导")。
41
+ */
42
+ export function pathFor(paths, tableId, fileFingerprint) {
43
+ return path.join(paths.checkpointsDir, `${sanitize(tableId)}-${sanitize(fileFingerprint)}.json`);
44
+ }
45
+
46
+ /** 表 id / 指纹理论上都是安全字符,但仍然过一道:它们会进文件名。 */
47
+ function sanitize(raw) {
48
+ return Array.from(String(raw ?? ''))
49
+ .map((ch) => (/[A-Za-z0-9_-]/.test(ch) ? ch : '_'))
50
+ .join('');
51
+ }
52
+
53
+ /** 一次导入的断点记录(普通对象;字段名与 Rust 的 `Checkpoint` 逐字对应)。 */
54
+ export function newCheckpoint({ tableId, fileFingerprint, rows, batchSize, batches, updatedAt }) {
55
+ return {
56
+ table_id: tableId,
57
+ file_fingerprint: fileFingerprint,
58
+ rows,
59
+ batch_size: batchSize,
60
+ updated_at: updatedAt,
61
+ batches: batches.map((batch) => ({
62
+ index: batch.index,
63
+ start: batch.start,
64
+ end: batch.end,
65
+ records: batch.end - batch.start,
66
+ idempotency_key: batch.idempotencyKey,
67
+ done: false,
68
+ })),
69
+ };
70
+ }
71
+
72
+ /** 已完成批数。 */
73
+ export function doneCount(checkpoint) {
74
+ return checkpoint.batches.filter((batch) => batch.done).length;
75
+ }
76
+
77
+ /** 已完成批覆盖的行数。 */
78
+ export function doneRecords(checkpoint) {
79
+ return checkpoint.batches
80
+ .filter((batch) => batch.done)
81
+ .reduce((sum, batch) => sum + batch.records, 0);
82
+ }
83
+
84
+ /** 计划里的每一批都已完成(重跑同一命令 = 全部幂等重放,不会写第二遍)。 */
85
+ export function isComplete(checkpoint) {
86
+ return checkpoint.batches.length > 0 && checkpoint.batches.every((batch) => batch.done);
87
+ }
88
+
89
+ function toValue(checkpoint) {
90
+ // `sortKeysDeep` 让落盘键序与 Rust 的 `serde_json`(BTreeMap)逐字节同形 ——
91
+ // 同一份进度在两种实现之间来回读,diff 里不该出现"只是键序不同"的噪声。
92
+ return sortKeysDeep({
93
+ version: VERSION,
94
+ table_id: checkpoint.table_id,
95
+ file_fingerprint: checkpoint.file_fingerprint,
96
+ rows: checkpoint.rows,
97
+ batch_size: checkpoint.batch_size,
98
+ updated_at: checkpoint.updated_at,
99
+ batches: checkpoint.batches.map((batch) => ({
100
+ index: batch.index,
101
+ start: batch.start,
102
+ end: batch.end,
103
+ records: batch.records,
104
+ done: batch.done,
105
+ idempotency_key: batch.idempotency_key,
106
+ })),
107
+ });
108
+ }
109
+
110
+ /** 读不懂的形状一律 `null`(缺失字段/类型不对 = 这份进度不能用)。 */
111
+ function fromValue(value) {
112
+ if (!value || typeof value !== 'object') return null;
113
+ if (value.version !== VERSION) return null;
114
+ if (!Array.isArray(value.batches)) return null;
115
+ const batches = [];
116
+ for (const batch of value.batches) {
117
+ if (!batch || typeof batch !== 'object') return null;
118
+ if (
119
+ typeof batch.index !== 'number' ||
120
+ typeof batch.start !== 'number' ||
121
+ typeof batch.end !== 'number' ||
122
+ typeof batch.records !== 'number' ||
123
+ typeof batch.idempotency_key !== 'string'
124
+ ) {
125
+ return null;
126
+ }
127
+ batches.push({
128
+ index: batch.index,
129
+ start: batch.start,
130
+ end: batch.end,
131
+ records: batch.records,
132
+ idempotency_key: batch.idempotency_key,
133
+ done: batch.done === true,
134
+ });
135
+ }
136
+ if (
137
+ typeof value.table_id !== 'string' ||
138
+ typeof value.file_fingerprint !== 'string' ||
139
+ typeof value.rows !== 'number' ||
140
+ typeof value.batch_size !== 'number'
141
+ ) {
142
+ return null;
143
+ }
144
+ return {
145
+ table_id: value.table_id,
146
+ file_fingerprint: value.file_fingerprint,
147
+ rows: value.rows,
148
+ batch_size: value.batch_size,
149
+ updated_at: typeof value.updated_at === 'string' ? value.updated_at : '',
150
+ batches,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * 读 checkpoint。文件不存在 → `null`;**读不懂也返回 `null`**(当作没有),
156
+ * 因为"磁盘上有半个 JSON"绝不该让导入直接失败 —— 最坏的结果只是从头再来。
157
+ */
158
+ export function load(file) {
159
+ let text;
160
+ try {
161
+ text = fs.readFileSync(file, 'utf8');
162
+ } catch {
163
+ return null;
164
+ }
165
+ try {
166
+ return fromValue(JSON.parse(text));
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /** 落盘(临时文件 + rename,见模块头)。 */
173
+ export function save(file, checkpoint) {
174
+ const parent = path.dirname(file);
175
+ try {
176
+ fs.mkdirSync(parent, { recursive: true });
177
+ } catch (err) {
178
+ throw CliError.local(`建不了 checkpoint 目录 ${parent}:${rustIoErrorText(err)}`);
179
+ }
180
+ const payload = JSON.stringify(toValue(checkpoint), null, 2);
181
+ const tmp = `${file}.tmp`;
182
+ try {
183
+ fs.writeFileSync(tmp, payload);
184
+ } catch (err) {
185
+ throw CliError.local(`写不了 checkpoint ${tmp}:${rustIoErrorText(err)}`);
186
+ }
187
+ try {
188
+ fs.renameSync(tmp, file);
189
+ } catch (err) {
190
+ throw CliError.local(`落不了 checkpoint ${file}:${rustIoErrorText(err)}`);
191
+ }
192
+ }
193
+
194
+ /** 删掉 checkpoint(导入完整成功后调用:下次重跑 = 新的一次导入)。 */
195
+ export function remove(file) {
196
+ try {
197
+ fs.rmSync(file, { force: true });
198
+ } catch {
199
+ /* 删不掉不是错误:下次会当作"没有进度" */
200
+ }
201
+ }
202
+
203
+ /** 文件在不在(`--fresh` 时用来决定要不要打印提示)。 */
204
+ export function exists(file) {
205
+ try {
206
+ fs.accessSync(file);
207
+ return true;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 旧 checkpoint 与这次的目标/批划分是否完全一致(逐批的起止行号都要对上)。
215
+ *
216
+ * 只比 `--batch-size` 是不够的:按体积二次切分让批边界依赖**具体内容**,
217
+ * 同样是 500 行上限,宽表可能切成 300+200。边界不同 → 幂等键不同 → 会写第二遍。
218
+ */
219
+ export function samePlan(existing, tableId, planned) {
220
+ if (existing.table_id !== tableId) return false;
221
+ if (existing.batches.length !== planned.length) return false;
222
+ return existing.batches.every((batch, index) => {
223
+ const other = planned[index];
224
+ return batch.index === other.index && batch.start === other.start && batch.end === other.end;
225
+ });
226
+ }