@fluxvita/jovida-cli 0.0.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.
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.entryToProto = entryToProto;
4
+ exports.entryFromProto = entryFromProto;
5
+ exports.recurringToProto = recurringToProto;
6
+ exports.recurringFromProto = recurringFromProto;
7
+ // ---- priority enum ↔ 本地字符串 ----
8
+ const PRIORITY_TO_PROTO = {
9
+ none: 'PRIORITY_NONE',
10
+ low: 'PRIORITY_LOW',
11
+ medium: 'PRIORITY_MEDIUM',
12
+ high: 'PRIORITY_HIGH'
13
+ };
14
+ // 名字或序号都接受(protojson 通常回名字,但容错数字)。UNSPECIFIED/缺省 → 'none'。
15
+ function priorityFromProto(v) {
16
+ switch (v) {
17
+ case 'PRIORITY_LOW':
18
+ case 2:
19
+ return 'low';
20
+ case 'PRIORITY_MEDIUM':
21
+ case 3:
22
+ return 'medium';
23
+ case 'PRIORITY_HIGH':
24
+ case 4:
25
+ return 'high';
26
+ default:
27
+ return 'none'; // PRIORITY_NONE(1) / UNSPECIFIED(0) / 省略
28
+ }
29
+ }
30
+ // int64:写出转字符串(canonical proto3-JSON);读入用 Number(时间戳/秒在 JS 安全整数内)。
31
+ function i64(n) {
32
+ return String(n);
33
+ }
34
+ function num(v) {
35
+ return v == null ? 0 : Number(v);
36
+ }
37
+ // ---- RepeatUnit / Weekday enum ↔ 本地 ----
38
+ const UNIT_TO_PROTO = {
39
+ day: 'REPEAT_UNIT_DAY',
40
+ week: 'REPEAT_UNIT_WEEK',
41
+ month: 'REPEAT_UNIT_MONTH',
42
+ year: 'REPEAT_UNIT_YEAR'
43
+ };
44
+ function unitFromProto(v) {
45
+ switch (v) {
46
+ case 'REPEAT_UNIT_WEEK':
47
+ case 2:
48
+ return 'week';
49
+ case 'REPEAT_UNIT_MONTH':
50
+ case 3:
51
+ return 'month';
52
+ case 'REPEAT_UNIT_YEAR':
53
+ case 4:
54
+ return 'year';
55
+ default:
56
+ return 'day'; // REPEAT_UNIT_DAY(1) / UNSPECIFIED(0) / 省略
57
+ }
58
+ }
59
+ const WEEKDAY_NAMES = [
60
+ 'WEEKDAY_UNSPECIFIED',
61
+ 'WEEKDAY_MONDAY',
62
+ 'WEEKDAY_TUESDAY',
63
+ 'WEEKDAY_WEDNESDAY',
64
+ 'WEEKDAY_THURSDAY',
65
+ 'WEEKDAY_FRIDAY',
66
+ 'WEEKDAY_SATURDAY',
67
+ 'WEEKDAY_SUNDAY'
68
+ ];
69
+ // 本地 ISO 1-7 → proto enum 名。
70
+ function weekdayToProto(iso) {
71
+ return WEEKDAY_NAMES[iso] ?? 'WEEKDAY_UNSPECIFIED';
72
+ }
73
+ // proto enum 名或序号 → 本地 ISO 1-7(无效返回 0,调用方过滤)。
74
+ function weekdayFromProto(v) {
75
+ if (typeof v === 'number')
76
+ return v >= 1 && v <= 7 ? v : 0;
77
+ const i = WEEKDAY_NAMES.indexOf(String(v));
78
+ return i >= 1 ? i : 0;
79
+ }
80
+ function entryToProto(e) {
81
+ const out = {
82
+ entryId: e.entryId,
83
+ title: e.title,
84
+ description: e.description,
85
+ category: e.category,
86
+ priority: PRIORITY_TO_PROTO[e.priority],
87
+ dueAt: i64(e.dueAt),
88
+ belongAt: i64(e.belongAt),
89
+ recurringId: e.recurringId,
90
+ occurrenceAt: i64(e.occurrenceAt),
91
+ subtasks: e.subtasks.map((s) => ({ id: s.id, title: s.title, completedAt: i64(s.completedAt) })),
92
+ completedAt: i64(e.completedAt),
93
+ createdAt: i64(e.createdAt),
94
+ updatedAt: i64(e.updatedAt)
95
+ };
96
+ if (e.reminder) {
97
+ out.reminder = {
98
+ id: e.reminder.id,
99
+ canAlarm: e.reminder.canAlarm,
100
+ offsetSecs: e.reminder.offsetSecs.map(i64)
101
+ };
102
+ }
103
+ return out;
104
+ }
105
+ function entryFromProto(o) {
106
+ const subtasks = (o.subtasks ?? []).map((s) => ({
107
+ id: s.id ?? '',
108
+ title: s.title ?? '',
109
+ completedAt: num(s.completedAt)
110
+ }));
111
+ let reminder = null;
112
+ if (o.reminder) {
113
+ reminder = {
114
+ id: o.reminder.id ?? '',
115
+ canAlarm: o.reminder.canAlarm ?? false,
116
+ offsetSecs: (o.reminder.offsetSecs ?? []).map(num)
117
+ };
118
+ }
119
+ return {
120
+ entryId: o.entryId ?? '',
121
+ title: o.title ?? '',
122
+ description: o.description ?? '',
123
+ category: o.category ?? '',
124
+ priority: priorityFromProto(o.priority),
125
+ dueAt: num(o.dueAt),
126
+ belongAt: num(o.belongAt),
127
+ recurringId: o.recurringId ?? '',
128
+ occurrenceAt: num(o.occurrenceAt),
129
+ subtasks,
130
+ reminder,
131
+ completedAt: num(o.completedAt),
132
+ createdAt: num(o.createdAt),
133
+ updatedAt: num(o.updatedAt),
134
+ hint: '' // hint 是本地列、不在同步 proto;importFromServer 会保留本地既有 hint,不被此 '' 覆盖
135
+ };
136
+ }
137
+ function recurringToProto(s) {
138
+ const out = {
139
+ recurringId: s.recurringId,
140
+ title: s.title,
141
+ description: s.description,
142
+ category: s.category,
143
+ priority: PRIORITY_TO_PROTO[s.priority],
144
+ dueAt: i64(s.dueAt),
145
+ belongAt: i64(s.belongAt),
146
+ subtasks: s.subtasks.map((t) => ({ id: t.id, title: t.title, completedAt: i64(t.completedAt) })),
147
+ repeatRule: {
148
+ unit: UNIT_TO_PROTO[s.repeat.unit],
149
+ interval: s.repeat.interval,
150
+ weekdays: s.repeat.weekdays.map(weekdayToProto),
151
+ dayOfMonth: s.repeat.dayOfMonth,
152
+ monthOfYear: s.repeat.monthOfYear,
153
+ endAt: i64(s.repeat.endAt)
154
+ },
155
+ createdAt: i64(s.createdAt),
156
+ updatedAt: i64(s.updatedAt)
157
+ };
158
+ if (s.reminder) {
159
+ out.reminder = {
160
+ id: s.reminder.id,
161
+ canAlarm: s.reminder.canAlarm,
162
+ offsetSecs: s.reminder.offsetSecs.map(i64)
163
+ };
164
+ }
165
+ return out;
166
+ }
167
+ function recurringFromProto(o) {
168
+ const subtasks = (o.subtasks ?? []).map((s) => ({
169
+ id: s.id ?? '',
170
+ title: s.title ?? '',
171
+ completedAt: num(s.completedAt)
172
+ }));
173
+ let reminder = null;
174
+ if (o.reminder) {
175
+ reminder = {
176
+ id: o.reminder.id ?? '',
177
+ canAlarm: o.reminder.canAlarm ?? false,
178
+ offsetSecs: (o.reminder.offsetSecs ?? []).map(num)
179
+ };
180
+ }
181
+ const r = o.repeatRule ?? {};
182
+ const repeat = {
183
+ unit: unitFromProto(r.unit),
184
+ interval: Math.max(1, num(r.interval)),
185
+ weekdays: (r.weekdays ?? []).map(weekdayFromProto).filter((w) => w >= 1 && w <= 7),
186
+ dayOfMonth: num(r.dayOfMonth),
187
+ monthOfYear: num(r.monthOfYear),
188
+ endAt: num(r.endAt)
189
+ };
190
+ return {
191
+ recurringId: o.recurringId ?? '',
192
+ title: o.title ?? '',
193
+ description: o.description ?? '',
194
+ category: o.category ?? '',
195
+ priority: priorityFromProto(o.priority),
196
+ dueAt: num(o.dueAt),
197
+ belongAt: num(o.belongAt),
198
+ subtasks,
199
+ reminder,
200
+ repeat,
201
+ createdAt: num(o.createdAt),
202
+ updatedAt: num(o.updatedAt)
203
+ };
204
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reminderAnchorSec = reminderAnchorSec;
4
+ exports.triggerTimesSec = triggerTimesSec;
5
+ exports.nextTriggerSec = nextTriggerSec;
6
+ const DAY_SECS = 86400;
7
+ /**
8
+ * reminder 触发锚:有精确 due 用 due,否则 belong 当天 24 点(次日 0 点)。
9
+ * 仅用于 reminder offset 计算,区别于 scope 查询锚(belong 当天 0 点)。
10
+ * ⚠️ 跨端契约:所有客户端须用同一锚定义,否则同步后提醒错时。
11
+ */
12
+ function reminderAnchorSec(dueAt, belongAt) {
13
+ if (dueAt && dueAt > 0)
14
+ return dueAt;
15
+ if (belongAt && belongAt > 0)
16
+ return belongAt + DAY_SECS;
17
+ return undefined;
18
+ }
19
+ /** entry 的所有提醒触发时刻(Unix 秒,= 锚 − 各 offset)。无 reminder / 无锚 → []。 */
20
+ function triggerTimesSec(entry) {
21
+ if (!entry.reminder || entry.reminder.offsetSecs.length === 0)
22
+ return [];
23
+ const anchor = reminderAnchorSec(entry.dueAt, entry.belongAt);
24
+ if (anchor === undefined)
25
+ return [];
26
+ return entry.reminder.offsetSecs.map((o) => anchor - o);
27
+ }
28
+ /** 最近的未来触发时刻(Unix 秒);无则 undefined(用于 UI 铃铛旁显示)。 */
29
+ function nextTriggerSec(entry, nowSec) {
30
+ const future = triggerTimesSec(entry)
31
+ .filter((t) => t > nowSec)
32
+ .sort((a, b) => a - b);
33
+ return future[0];
34
+ }
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ // 领域类型(Jovida 待办)。时间一律 Unix 秒(0=未设/未完成)。
3
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/ctx.js ADDED
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeCtx = makeCtx;
4
+ const config_1 = require("./config");
5
+ const api_1 = require("./api");
6
+ const session_1 = require("./session");
7
+ const sync_1 = require("./sync");
8
+ const state_1 = require("./state");
9
+ function makeCtx() {
10
+ const cfg = (0, config_1.loadConfig)();
11
+ const api = new api_1.ApiClient({
12
+ baseUrl: cfg.baseUrl,
13
+ appId: cfg.appId,
14
+ deviceId: (0, state_1.getDeviceId)(),
15
+ platform: (0, config_1.platformName)()
16
+ });
17
+ const session = new session_1.Session(api);
18
+ const sync = new sync_1.SyncClient(api);
19
+ return { api, session, sync, baseUrl: cfg.baseUrl };
20
+ }
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tryOpenBrowser = tryOpenBrowser;
4
+ const node_child_process_1 = require("node:child_process");
5
+ /** 尽力在系统浏览器打开 URL;失败静默(headless 服务器无浏览器属正常)。返回是否尝试成功派生进程。 */
6
+ function tryOpenBrowser(url) {
7
+ try {
8
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
9
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
10
+ const child = (0, node_child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true });
11
+ child.on('error', () => { });
12
+ child.unref();
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deriveDeviceId = deriveDeviceId;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_fs_1 = require("node:fs");
6
+ const node_crypto_1 = require("node:crypto");
7
+ // 设备标识(Vita-Did)派生。目标:删 app 再装回来不变 —— 故派生自**OS 机器标识**,
8
+ // 而非存一个 UUID(UUID 落在 app userData,卸载即丢)。机器标识能扛卸载重装/清数据,
9
+ // 扛不住重装系统/换机/抹盘。取不到时降级随机 UUID(那种会重置)。
10
+ // 隐私:原始硬件 ID 不外发,加盐 SHA-256 后用(app 维度隔离)。
11
+ function rawMachineId() {
12
+ try {
13
+ if (process.platform === 'darwin') {
14
+ const out = (0, node_child_process_1.execFileSync)('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], {
15
+ encoding: 'utf8'
16
+ });
17
+ return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1] ?? null;
18
+ }
19
+ if (process.platform === 'win32') {
20
+ const out = (0, node_child_process_1.execFileSync)('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8' });
21
+ return /MachineGuid\s+REG_SZ\s+([A-Fa-f0-9-]+)/.exec(out)?.[1] ?? null;
22
+ }
23
+ // linux
24
+ for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
25
+ try {
26
+ const id = (0, node_fs_1.readFileSync)(p, 'utf8').trim();
27
+ if (id)
28
+ return id;
29
+ }
30
+ catch {
31
+ /* try next */
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ const SALT = 'jovida-cli-did-v1';
41
+ /** 派生 device-id。stable=true 表示来自机器标识(重装稳定);false=降级随机(会重置)。 */
42
+ function deriveDeviceId() {
43
+ const raw = rawMachineId();
44
+ if (raw) {
45
+ const h = (0, node_crypto_1.createHash)('sha256').update(SALT).update(raw).digest('hex').slice(0, 32);
46
+ return { id: `dvc_${h}`, stable: true };
47
+ }
48
+ return { id: `dvc_${(0, node_crypto_1.randomUUID)().replace(/-/g, '')}`, stable: false };
49
+ }
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Session = exports.NotSignedInError = void 0;
4
+ // 鉴权 session。**正式 CLI 不支持匿名态**——必须先 `jovida login`。
5
+ //
6
+ // 登录 = OAuth 设备授权流(RFC 8628 语义,vita 线格式):
7
+ // device_authorize(匿名)拿 deviceCode(密)+userCode(短码) → 用户在浏览器登录批准
8
+ // → 轮询 device_token,reason=="" 即批准、返回 Sign 态 vita token → 落盘 token.raw。
9
+ // 凭证 = 单枚 vita token(raw 内含 access/refresh 双窗);access 临期用 refresh_token 续;
10
+ // refresh 死 → NotSignedIn,重跑 login。**不走 apikey/Bearer。**
11
+ // 过渡:`loginWithToken` 直粘一枚 Sign token(开发期,无 durs 故不自动 refresh)。
12
+ const api_1 = require("./api");
13
+ const state_1 = require("./state");
14
+ const AUTHORIZE = '/uc/v1/passport/device_authorize';
15
+ const DEVICE_TOKEN = '/uc/v1/passport/device_token';
16
+ const REFRESH = '/uc/v1/passport/refresh_token';
17
+ const USER_INFO = '/uc/v1/user/get_user_info';
18
+ const SKEW = 60;
19
+ const nowSec = () => Math.floor(Date.now() / 1000);
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ /** 未登录 / 会话失效。CLI 不回退匿名。 */
22
+ class NotSignedInError extends Error {
23
+ constructor(msg = 'Not signed in. Run `jovida login` first.') {
24
+ super(msg);
25
+ this.name = 'NotSignedInError';
26
+ }
27
+ }
28
+ exports.NotSignedInError = NotSignedInError;
29
+ class Session {
30
+ api;
31
+ refreshing = null;
32
+ constructor(api) {
33
+ this.api = api;
34
+ const t = (0, state_1.getToken)();
35
+ if (t)
36
+ api.setToken(t.raw);
37
+ }
38
+ /** 业务命令前:无 token → NotSignedIn;access 临期 → 续期。 */
39
+ async ensureSession() {
40
+ const t = (0, state_1.getToken)();
41
+ if (!t)
42
+ throw new NotSignedInError();
43
+ this.api.setToken(t.raw);
44
+ if (t.accessDur > 0 && nowSec() > t.receivedAt + t.accessDur - SKEW)
45
+ await this.refresh();
46
+ }
47
+ // ── 设备授权流 ────────────────────────────────────────────────
48
+ /** 第 1 步:发起。device endpoints 匿名(清掉可能的旧 token)。 */
49
+ async deviceAuthorize() {
50
+ this.api.setToken('');
51
+ return this.api.post(AUTHORIZE, {});
52
+ }
53
+ /**
54
+ * 第 2 步:按 interval 轮询直到批准 / 拒绝 / 过期 / 超时。
55
+ * reason: ""=已批准(带 token)、AUTHORIZATION_PENDING=继续、SLOW_DOWN=加间隔、ACCESS_DENIED/EXPIRED_TOKEN=终止。
56
+ */
57
+ async pollForToken(d) {
58
+ let interval = Math.max(1, d.interval || 5);
59
+ const deadline = nowSec() + (d.expiresIn || 600);
60
+ for (;;) {
61
+ if (nowSec() >= deadline)
62
+ throw new Error('Login timed out before approval. Run `jovida login` again.');
63
+ await sleep(interval * 1000);
64
+ const resp = await this.api.post(DEVICE_TOKEN, { deviceCode: d.deviceCode });
65
+ if (resp.token?.raw)
66
+ return this.applyToken(resp); // reason=="" 已批准
67
+ switch (resp.reason ?? '') {
68
+ case 'AUTHORIZATION_PENDING':
69
+ case '':
70
+ continue;
71
+ case 'SLOW_DOWN':
72
+ interval += 5;
73
+ continue;
74
+ case 'ACCESS_DENIED':
75
+ throw new Error('Login was denied.');
76
+ case 'EXPIRED_TOKEN':
77
+ throw new Error('The login request expired. Run `jovida login` again.');
78
+ default:
79
+ throw new Error(`Unexpected device_token reason: ${resp.reason}`);
80
+ }
81
+ }
82
+ }
83
+ /** 设备流登录:authorize → present(展示 URL+短码 + 开浏览器) → 轮询落盘。 */
84
+ async loginWithDeviceFlow(present) {
85
+ const d = await this.deviceAuthorize();
86
+ present(d);
87
+ return this.pollForToken(d);
88
+ }
89
+ // ── 过渡 / 续期 / 身份 ─────────────────────────────────────────
90
+ /** 过渡登录(开发期):直粘 Sign 态 vita token,get_user_info 验活后落盘(durs=0,不自动 refresh)。 */
91
+ async loginWithToken(rawToken) {
92
+ const raw = rawToken.trim();
93
+ if (!raw)
94
+ throw new Error('empty token');
95
+ this.api.setToken(raw);
96
+ const info = await this.fetchUserInfo('That token is not a valid signed-in session.');
97
+ const rec = { raw, vitaId: info.vitaId, accessDur: 0, refreshDur: 0, receivedAt: nowSec() };
98
+ (0, state_1.setToken)(rec);
99
+ return rec;
100
+ }
101
+ async refresh() {
102
+ if (this.refreshing)
103
+ return this.refreshing;
104
+ this.refreshing = (async () => {
105
+ try {
106
+ const resp = await this.api.post(REFRESH, {});
107
+ this.applyToken(resp);
108
+ }
109
+ catch (e) {
110
+ if (e instanceof api_1.ApiError && (e.status === 401 || e.status === 403)) {
111
+ (0, state_1.clearCredentials)();
112
+ throw new NotSignedInError('Session expired. Run `jovida login` again.');
113
+ }
114
+ throw e;
115
+ }
116
+ finally {
117
+ this.refreshing = null;
118
+ }
119
+ })();
120
+ return this.refreshing;
121
+ }
122
+ /** 当前身份(在线查;无凭证 → NotSignedIn)。 */
123
+ async whoami() {
124
+ await this.ensureSession();
125
+ return this.fetchUserInfo('Session is no longer valid.');
126
+ }
127
+ async fetchUserInfo(rejectMsg) {
128
+ let resp;
129
+ try {
130
+ resp = await this.api.get(USER_INFO);
131
+ }
132
+ catch (e) {
133
+ if (e instanceof api_1.ApiError && (e.status === 401 || e.status === 403)) {
134
+ (0, state_1.clearCredentials)();
135
+ throw new NotSignedInError(`${rejectMsg} Run \`jovida login\` again.`);
136
+ }
137
+ throw e;
138
+ }
139
+ return {
140
+ vitaId: String(resp.user?.vitaId ?? ''),
141
+ vitaHao: resp.user?.vitaHao ?? '',
142
+ entitlement: resp.subscription?.entitlement ?? ''
143
+ };
144
+ }
145
+ applyToken(resp) {
146
+ const tk = resp.token;
147
+ if (!tk?.raw)
148
+ throw new Error('passport response missing token');
149
+ const rec = {
150
+ raw: tk.raw,
151
+ vitaId: String(resp.register?.vitaId ?? (0, state_1.getToken)()?.vitaId ?? ''),
152
+ accessDur: Number(tk.accessDur ?? 0),
153
+ refreshDur: Number(tk.refreshDur ?? 0),
154
+ receivedAt: nowSec()
155
+ };
156
+ (0, state_1.setToken)(rec);
157
+ this.api.setToken(rec.raw);
158
+ return rec;
159
+ }
160
+ }
161
+ exports.Session = Session;
package/dist/state.js ADDED
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getDeviceId = getDeviceId;
4
+ exports.getLastServerVersion = getLastServerVersion;
5
+ exports.setLastServerVersion = setLastServerVersion;
6
+ exports.getToken = getToken;
7
+ exports.setToken = setToken;
8
+ exports.clearCredentials = clearCredentials;
9
+ // 本地状态:~/.jovida/(可经 JOVIDA_HOME 覆盖)。无待办库——storeless。
10
+ // credentials.json(token,0600)+ state.json(deviceId / lastServerVersion)。
11
+ const node_os_1 = require("node:os");
12
+ const node_path_1 = require("node:path");
13
+ const node_fs_1 = require("node:fs");
14
+ const machine_id_1 = require("./machine-id");
15
+ const DIR = process.env['JOVIDA_HOME'] ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.jovida');
16
+ const CRED = (0, node_path_1.join)(DIR, 'credentials.json');
17
+ const STATE = (0, node_path_1.join)(DIR, 'state.json');
18
+ function ensureDir() {
19
+ if (!(0, node_fs_1.existsSync)(DIR))
20
+ (0, node_fs_1.mkdirSync)(DIR, { recursive: true, mode: 0o700 });
21
+ }
22
+ function readJson(p) {
23
+ try {
24
+ return JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function loadState() {
31
+ return readJson(STATE) ?? {};
32
+ }
33
+ function saveState(s) {
34
+ ensureDir();
35
+ (0, node_fs_1.writeFileSync)(STATE, JSON.stringify(s, null, 2));
36
+ }
37
+ /** Vita-Did:首次派生(机器标识)并持久化;重装后由 machine-id 派生回同值。 */
38
+ function getDeviceId() {
39
+ const s = loadState();
40
+ if (s.deviceId)
41
+ return s.deviceId;
42
+ const { id, stable } = (0, machine_id_1.deriveDeviceId)();
43
+ s.deviceId = id;
44
+ s.didStable = stable;
45
+ saveState(s);
46
+ return id;
47
+ }
48
+ function getLastServerVersion() {
49
+ return loadState().lastServerVersion ?? 0;
50
+ }
51
+ function setLastServerVersion(v) {
52
+ const s = loadState();
53
+ s.lastServerVersion = v;
54
+ saveState(s);
55
+ }
56
+ function readCreds() {
57
+ return readJson(CRED);
58
+ }
59
+ function writeCreds(c) {
60
+ ensureDir();
61
+ (0, node_fs_1.writeFileSync)(CRED, JSON.stringify(c), { mode: 0o600 });
62
+ }
63
+ function getToken() {
64
+ return readCreds()?.token ?? null;
65
+ }
66
+ function setToken(rec) {
67
+ const c = readCreds() ?? {};
68
+ c.token = rec;
69
+ writeCreds(c);
70
+ }
71
+ /** 退出:清 token。 */
72
+ function clearCredentials() {
73
+ try {
74
+ (0, node_fs_1.rmSync)(CRED);
75
+ }
76
+ catch {
77
+ /* 不存在即忽略 */
78
+ }
79
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SyncClient = void 0;
4
+ // 同步原语:在线读改写(put/get snapshot + OCC)。CLI storeless,无本地库/对账。
5
+ // 请求形与后端一致:PUT={ dataset:{entries,recurrings}, baseServerVersion };GET={ expectedServerVersion, pageToken, snapshotToken };均 proto3-JSON。
6
+ const api_1 = require("./api");
7
+ const state_1 = require("./state");
8
+ const proto_1 = require("./core/proto");
9
+ const PUT = '/jov/todo/v1/put_todo_snapshot';
10
+ const GET = '/jov/todo/v1/get_todo_snapshot';
11
+ const DELETE = '/jov/todo/v1/delete_todo_objects';
12
+ const MAX_CONFLICT = 3; // put 409(落后)→pull→重试
13
+ const MAX_EXPIRED = 3; // get 409(分页快照过期)→首页重拉
14
+ class SyncClient {
15
+ api;
16
+ constructor(api) {
17
+ this.api = api;
18
+ }
19
+ /** 全量拉取(CLI storeless:每次强制全量,expectedServerVersion=0)。处理分页 + 409 SNAPSHOT_EXPIRED 重拉。 */
20
+ async pull() {
21
+ for (let attempt = 0; attempt < MAX_EXPIRED; attempt++) {
22
+ const r = await this.pullOnce();
23
+ if (r) {
24
+ (0, state_1.setLastServerVersion)(r.serverVersion);
25
+ return r;
26
+ }
27
+ }
28
+ throw new Error('snapshot kept expiring during pagination');
29
+ }
30
+ async pullOnce() {
31
+ const entries = [];
32
+ const recurrings = [];
33
+ let pageToken = '';
34
+ let snapshotToken = '';
35
+ let serverVersion = 0;
36
+ for (;;) {
37
+ let resp;
38
+ try {
39
+ resp = await this.api.post(GET, {
40
+ expectedServerVersion: '0', // 强制全量(storeless 无本地副本可省传)
41
+ pageToken,
42
+ snapshotToken
43
+ });
44
+ }
45
+ catch (e) {
46
+ if (e instanceof api_1.ApiError && e.status === 409)
47
+ return null; // SNAPSHOT_EXPIRED → 外层重拉
48
+ throw e;
49
+ }
50
+ serverVersion = resp.serverVersion != null ? Number(resp.serverVersion) : serverVersion;
51
+ if (!snapshotToken && resp.snapshotToken)
52
+ snapshotToken = resp.snapshotToken;
53
+ for (const o of resp.objects ?? []) {
54
+ if (o.entry)
55
+ entries.push((0, proto_1.entryFromProto)(o.entry));
56
+ else if (o.recurring)
57
+ recurrings.push((0, proto_1.recurringFromProto)(o.recurring));
58
+ }
59
+ if (!resp.hasMore || !resp.nextPageToken)
60
+ break;
61
+ pageToken = resp.nextPageToken;
62
+ }
63
+ return { entries, recurrings, serverVersion };
64
+ }
65
+ /** 增量 upsert entries。409 SYNC_CONFLICT → pull 追平版本 → 重试。 */
66
+ async putEntries(items) {
67
+ for (let attempt = 0; attempt <= MAX_CONFLICT; attempt++) {
68
+ try {
69
+ await this.api.post(PUT, {
70
+ dataset: { entries: items.map(proto_1.entryToProto), recurrings: [] },
71
+ baseServerVersion: String((0, state_1.getLastServerVersion)())
72
+ });
73
+ return;
74
+ }
75
+ catch (e) {
76
+ if (e instanceof api_1.ApiError && e.status === 409 && attempt < MAX_CONFLICT) {
77
+ await this.pull(); // 追平 lastServerVersion 后重试
78
+ continue;
79
+ }
80
+ throw e;
81
+ }
82
+ }
83
+ }
84
+ /** 增量 upsert 循环「类」(recurrings)。409 SYNC_CONFLICT → pull 追平 → 重试。 */
85
+ async putRecurrings(items) {
86
+ for (let attempt = 0; attempt <= MAX_CONFLICT; attempt++) {
87
+ try {
88
+ await this.api.post(PUT, {
89
+ dataset: { entries: [], recurrings: items.map(proto_1.recurringToProto) },
90
+ baseServerVersion: String((0, state_1.getLastServerVersion)())
91
+ });
92
+ return;
93
+ }
94
+ catch (e) {
95
+ if (e instanceof api_1.ApiError && e.status === 409 && attempt < MAX_CONFLICT) {
96
+ await this.pull();
97
+ continue;
98
+ }
99
+ throw e;
100
+ }
101
+ }
102
+ }
103
+ /** 逐条硬删(无 OCC 门控;对未知 id 幂等)。 */
104
+ async deleteObjects(ids) {
105
+ for (const objectId of ids)
106
+ await this.api.post(DELETE, { objectId });
107
+ }
108
+ }
109
+ exports.SyncClient = SyncClient;