opencode-claude-code-ua 0.0.0-stage → 0.3.6
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/LICENSE +23 -0
- package/NOTICE.md +9 -0
- package/README.md +196 -2
- package/assets/icon.svg +9 -0
- package/index.mjs +23 -0
- package/lib/parameters.mjs +108 -0
- package/lib/ua-protocol.mjs +43 -0
- package/lib/ua-test-plan.mjs +232 -0
- package/package.json +80 -4
- package/setup.mjs +39 -0
- package/src/account-profile.mjs +73 -0
- package/src/accounts.mjs +281 -0
- package/src/connection.mjs +144 -0
- package/src/diagnostics.mjs +37 -0
- package/src/headers.mjs +60 -0
- package/src/newapi.mjs +237 -0
- package/src/options.mjs +48 -0
- package/src/plugin.mjs +374 -0
- package/src/profile.mjs +217 -0
- package/src/runtime.mjs +39 -0
- package/src/schedule.mjs +88 -0
- package/src/scheduler.mjs +141 -0
- package/src/setup-server.mjs +465 -0
- package/src/store.mjs +58 -0
- package/src/transport.mjs +84 -0
- package/src/ua-model-catalog.mjs +218 -0
- package/src/ua-plugin.mjs +207 -0
- package/src/ua-runtime.mjs +229 -0
- package/src/ua-service.mjs +97 -0
- package/src/ua-store.mjs +657 -0
- package/src/ua-tests.mjs +153 -0
- package/src/ua-transform.mjs +478 -0
- package/ui/accounts.css +75 -0
- package/ui/accounts.html +57 -0
- package/ui/accounts.js +582 -0
- package/ui/pro.css +1682 -0
- package/ui/pro.html +348 -0
- package/ui/pro.js +830 -0
- package/ui/theme.js +39 -0
package/src/schedule.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const DEFAULTS = Object.freeze({ enabled: false, time: '09:00', timeZone: 'UTC' });
|
|
2
|
+
const KEYS = new Set(['enabled', 'time', 'timeZone']);
|
|
3
|
+
const formatters = new Map();
|
|
4
|
+
const FORMATTER_LIMIT = 64;
|
|
5
|
+
|
|
6
|
+
/** Validation messages never include supplied values or unknown property names. */
|
|
7
|
+
export class ScheduleValidationError extends Error {
|
|
8
|
+
constructor(field = 'schedule') {
|
|
9
|
+
super('Invalid schedule.');
|
|
10
|
+
this.name = 'ScheduleValidationError';
|
|
11
|
+
this.fields = { [field]: 'Invalid value.' };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatterFor(timeZone) {
|
|
16
|
+
// Intl versions may accept numeric offsets. Only named zones are permitted here.
|
|
17
|
+
if (typeof timeZone !== 'string' || !/^[A-Za-z]/u.test(timeZone)
|
|
18
|
+
|| /[^A-Za-z0-9._+\/-]/u.test(timeZone)) {
|
|
19
|
+
throw new ScheduleValidationError('schedule.timeZone');
|
|
20
|
+
}
|
|
21
|
+
if (formatters.has(timeZone)) return formatters.get(timeZone);
|
|
22
|
+
let formatter;
|
|
23
|
+
try {
|
|
24
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
25
|
+
timeZone,
|
|
26
|
+
calendar: 'iso8601',
|
|
27
|
+
numberingSystem: 'latn',
|
|
28
|
+
hourCycle: 'h23',
|
|
29
|
+
year: 'numeric',
|
|
30
|
+
month: '2-digit',
|
|
31
|
+
day: '2-digit',
|
|
32
|
+
hour: '2-digit',
|
|
33
|
+
minute: '2-digit',
|
|
34
|
+
});
|
|
35
|
+
} catch {
|
|
36
|
+
throw new ScheduleValidationError('schedule.timeZone');
|
|
37
|
+
}
|
|
38
|
+
const entry = { formatter, timeZone: formatter.resolvedOptions().timeZone };
|
|
39
|
+
if (formatters.size >= FORMATTER_LIMIT) formatters.delete(formatters.keys().next().value);
|
|
40
|
+
formatters.set(timeZone, entry);
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Return a new, canonical schedule. Missing or undefined properties use defaults. */
|
|
45
|
+
export function normalizeSchedule(raw) {
|
|
46
|
+
if (raw === undefined) return { ...DEFAULTS };
|
|
47
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)
|
|
48
|
+
|| (Object.getPrototypeOf(raw) !== Object.prototype && Object.getPrototypeOf(raw) !== null)
|
|
49
|
+
|| Reflect.ownKeys(raw).some((key) => !KEYS.has(key))) {
|
|
50
|
+
throw new ScheduleValidationError();
|
|
51
|
+
}
|
|
52
|
+
const { enabled = false, time = '09:00', timeZone = 'UTC' } = raw;
|
|
53
|
+
if (typeof enabled !== 'boolean') throw new ScheduleValidationError('schedule.enabled');
|
|
54
|
+
if (typeof time !== 'string' || time.length !== 5 || !/^(?:[01]\d|2[0-3]):[0-5]\d$/u.test(time)) {
|
|
55
|
+
throw new ScheduleValidationError('schedule.time');
|
|
56
|
+
}
|
|
57
|
+
return { enabled, time, timeZone: formatterFor(timeZone).timeZone };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function localParts(at, timeZone) {
|
|
61
|
+
const milliseconds = at instanceof Date ? Date.prototype.getTime.call(at) : at;
|
|
62
|
+
if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)
|
|
63
|
+
|| !Number.isFinite(new Date(milliseconds).getTime())) {
|
|
64
|
+
throw new ScheduleValidationError('schedule.at');
|
|
65
|
+
}
|
|
66
|
+
const entry = formatterFor(timeZone);
|
|
67
|
+
const parts = Object.fromEntries(entry.formatter.formatToParts(milliseconds)
|
|
68
|
+
.map(({ type, value }) => [type, value]));
|
|
69
|
+
return {
|
|
70
|
+
date: `${parts.year.padStart(4, '0')}-${parts.month}-${parts.day}`,
|
|
71
|
+
time: `${parts.hour}:${parts.minute}`,
|
|
72
|
+
timeZone: entry.timeZone,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Ledger date in the explicit zone; at must be a valid Date or epoch milliseconds. */
|
|
77
|
+
export function scheduleDate(at, timeZone) {
|
|
78
|
+
return localParts(at, timeZone).date;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Exact local-minute matching only: no catch-up, timers, or DST repair heuristics. */
|
|
82
|
+
export function scheduleOccurrence(schedule, at = Date.now()) {
|
|
83
|
+
if (schedule === null || schedule === undefined) return null;
|
|
84
|
+
const normalized = normalizeSchedule(schedule);
|
|
85
|
+
if (!normalized.enabled) return null;
|
|
86
|
+
const occurrence = localParts(at, normalized.timeZone);
|
|
87
|
+
return occurrence.time === normalized.time ? occurrence : null;
|
|
88
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { scheduleOccurrence, scheduleDate } from './schedule.mjs';
|
|
5
|
+
|
|
6
|
+
const schedulers = new Map();
|
|
7
|
+
|
|
8
|
+
function identityOf(account) {
|
|
9
|
+
const url = new URL(account.siteUrl);
|
|
10
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}\n${account.userId}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createCheckinScheduler({
|
|
14
|
+
accountManager,
|
|
15
|
+
stateDirectory,
|
|
16
|
+
now = Date.now,
|
|
17
|
+
setIntervalImpl = setInterval,
|
|
18
|
+
clearIntervalImpl = clearInterval,
|
|
19
|
+
onError = () => {},
|
|
20
|
+
io = fs,
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (typeof stateDirectory !== 'string' || !isAbsolute(stateDirectory)) throw new Error('签到调度记录必须使用绝对路径');
|
|
23
|
+
let timer;
|
|
24
|
+
let active;
|
|
25
|
+
|
|
26
|
+
async function claim(identity, occurrence, at) {
|
|
27
|
+
await io.mkdir(stateDirectory, { recursive: true, mode: 0o700 });
|
|
28
|
+
const info = await io.lstat(stateDirectory);
|
|
29
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('签到调度目录无效');
|
|
30
|
+
const digest = createHash('sha256').update(identity).digest('hex');
|
|
31
|
+
const path = join(stateDirectory, `${occurrence.date}-${digest}.json`);
|
|
32
|
+
const lockPath = join(stateDirectory, `${digest}.lock`);
|
|
33
|
+
const lastPath = join(stateDirectory, `${digest}.last.json`);
|
|
34
|
+
const temporary = join(stateDirectory, `${digest}-${randomUUID()}.tmp`);
|
|
35
|
+
let lock, file;
|
|
36
|
+
try {
|
|
37
|
+
lock = await io.open(lockPath, 'wx', 0o600);
|
|
38
|
+
try {
|
|
39
|
+
const stat = await io.lstat(lastPath);
|
|
40
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2048) throw new Error('调度状态无效');
|
|
41
|
+
const last = JSON.parse(await io.readFile(lastPath, 'utf8'));
|
|
42
|
+
const previousAt = Date.parse(last.at);
|
|
43
|
+
if (!Number.isFinite(previousAt) || typeof last.timeZone !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(last.date) || scheduleDate(previousAt, last.timeZone) !== last.date) throw new Error('调度状态无效');
|
|
44
|
+
if (previousAt > at || scheduleDate(previousAt, occurrence.timeZone) === occurrence.date ||
|
|
45
|
+
scheduleDate(at, last.timeZone) === last.date) return false;
|
|
46
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
47
|
+
file = await io.open(path, 'wx', 0o600);
|
|
48
|
+
const content = JSON.stringify({ at: new Date(at).toISOString(), ...occurrence });
|
|
49
|
+
await file.writeFile(content, 'utf8');
|
|
50
|
+
await file.sync();
|
|
51
|
+
await file.close(); file = undefined;
|
|
52
|
+
await io.writeFile(temporary, content, { flag: 'wx', mode: 0o600 });
|
|
53
|
+
await io.rename(temporary, lastPath);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code === 'EEXIST') return false;
|
|
57
|
+
throw error;
|
|
58
|
+
} finally {
|
|
59
|
+
await file?.close();
|
|
60
|
+
if (lock) {
|
|
61
|
+
await lock.close();
|
|
62
|
+
await io.unlink(lockPath);
|
|
63
|
+
await io.unlink(temporary).catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function run() {
|
|
69
|
+
const attempted = [], errors = [];
|
|
70
|
+
let accounts;
|
|
71
|
+
try { ({ accounts } = await accountManager.list()); }
|
|
72
|
+
catch { return { attempted, error: '无法读取账号,未执行定时签到' }; }
|
|
73
|
+
const identities = new Set();
|
|
74
|
+
const due = accounts.filter((account) => {
|
|
75
|
+
if (!account.enabled || !account.checkinEnabled || !account.schedule?.enabled) return false;
|
|
76
|
+
try {
|
|
77
|
+
const occurrence = scheduleOccurrence(account.schedule, Number(now()));
|
|
78
|
+
if (!occurrence) return false;
|
|
79
|
+
const identity = identityOf(account);
|
|
80
|
+
if (accounts.some((other) => {
|
|
81
|
+
const checkedAt = Date.parse(other.checkin?.checkedAt);
|
|
82
|
+
return Number.isFinite(checkedAt) && identityOf(other) === identity && scheduleDate(checkedAt, occurrence.timeZone) === occurrence.date;
|
|
83
|
+
})) return false;
|
|
84
|
+
if (identities.has(identity)) return false;
|
|
85
|
+
identities.add(identity);
|
|
86
|
+
return true;
|
|
87
|
+
} catch { errors.push(account.id); return false; }
|
|
88
|
+
});
|
|
89
|
+
let next = 0;
|
|
90
|
+
async function worker() {
|
|
91
|
+
while (next < due.length) {
|
|
92
|
+
const account = due[next++];
|
|
93
|
+
const at = Number(now());
|
|
94
|
+
try {
|
|
95
|
+
const occurrence = scheduleOccurrence(account.schedule, at);
|
|
96
|
+
if (!occurrence || !await claim(identityOf(account), occurrence, at)) continue;
|
|
97
|
+
attempted.push(account.id);
|
|
98
|
+
const result = await accountManager.checkin(account.id, { expectedRevision: account.revision });
|
|
99
|
+
if (['error', 'failed'].includes(result?.checkin?.state)) errors.push(account.id);
|
|
100
|
+
} catch { errors.push(account.id); }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
await Promise.all(Array.from({ length: Math.min(4, due.length) }, worker));
|
|
104
|
+
return { attempted, ...(errors.length ? { error: '部分自动签到未执行或未完成,请检查账号记录与调度目录权限;不会自动重试', accounts: errors } : {}) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tick() {
|
|
108
|
+
if (active) return active;
|
|
109
|
+
active = run().finally(() => { active = undefined; });
|
|
110
|
+
return active;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function start() {
|
|
114
|
+
if (timer !== undefined) return;
|
|
115
|
+
timer = setIntervalImpl(async () => {
|
|
116
|
+
try {
|
|
117
|
+
const result = await tick();
|
|
118
|
+
if (result.error) await onError(result.error);
|
|
119
|
+
} catch { /* A reporting failure must not repeat a check-in. */ }
|
|
120
|
+
}, 15000);
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stop() {
|
|
125
|
+
if (timer !== undefined) clearIntervalImpl(timer);
|
|
126
|
+
timer = undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { start, stop, tick };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function ensureCheckinScheduler(options) {
|
|
133
|
+
const path = resolve(options.stateDirectory);
|
|
134
|
+
const key = process.platform === 'win32' ? path.toLowerCase() : path;
|
|
135
|
+
if (!schedulers.has(key)) {
|
|
136
|
+
const scheduler = createCheckinScheduler(options);
|
|
137
|
+
schedulers.set(key, scheduler);
|
|
138
|
+
scheduler.start();
|
|
139
|
+
}
|
|
140
|
+
return schedulers.get(key);
|
|
141
|
+
}
|