@lionrapid/cli 0.2.2 → 0.4.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,106 @@
1
+ /**
2
+ * Local record of the sync jobs this machine has queued — `~/.lionrapid/jobs.json`.
3
+ *
4
+ * Why a file at all: `sync/batch` answers 202 (queued), so the only proof a push
5
+ * actually landed is the job's later state. You push, close the terminal, and
6
+ * come back — without this file the job ids are gone with the scrollback and
7
+ * there is nothing left to ask the server about.
8
+ *
9
+ * Why NOT in `~/.lionrapid/credentials`: different lifetime (history is
10
+ * disposable, a key is not) and different sensitivity. A corrupt or truncated
11
+ * history must never cost someone their login, so the two files never share a
12
+ * parser or a write. Everything here degrades to "no history" instead of
13
+ * throwing — losing visibility is bad, but failing a push because a cache file
14
+ * is unreadable is worse.
15
+ *
16
+ * The server purges finished non-DLQ jobs after ~1h, so for anything older this
17
+ * file is the ONLY answer available: a terminal status is recorded when we see
18
+ * it, and later reads report it from disk without a network call.
19
+ */
20
+ /**
21
+ * One queued job, as this machine knew it. `files`/`keys` describe what was in
22
+ * the batch, so `lionrapid status` can say *what* is at risk without a network
23
+ * call.
24
+ */
25
+ export interface JobHistoryEntry {
26
+ /** Server-assigned job id — the key for `GET /sync/jobs/{id}`. */
27
+ id: string;
28
+ /** Server this job was queued on (a machine may push to more than one). */
29
+ server: string;
30
+ /**
31
+ * The project uuid this push resolved to, when one is known.
32
+ *
33
+ * Replaces the old free-text `project` label copied from lionrapid.yml —
34
+ * the field that stamped `tm-frontpage` onto 30 jobs that went elsewhere.
35
+ * Optional both because older entries carry the label instead and because
36
+ * the server's project id is itself optional.
37
+ */
38
+ projectId?: string;
39
+ /** Legacy: the free-text label, on entries written before the id existed. */
40
+ project?: string;
41
+ namespace: string;
42
+ /** Source locale of the batch. */
43
+ locale: string;
44
+ /** Files whose keys were in this batch (one, unless a file was chunked). */
45
+ files: string[];
46
+ /** Keys in the batch. */
47
+ keys: number;
48
+ /** ISO timestamp of the push that queued it. */
49
+ pushedAt: string;
50
+ /**
51
+ * Last status seen: the server's own vocabulary (`pending`, `processing`,
52
+ * `retry_pending`, `completed`, `failed`), or `gone` when the server no
53
+ * longer knows the job.
54
+ */
55
+ status: string;
56
+ /** ISO timestamp of the last status check against the server. */
57
+ checkedAt?: string;
58
+ /** Last error the server reported for this job. */
59
+ error?: string;
60
+ /** Why the backend gave up, when it did. */
61
+ deadLetterReason?: string;
62
+ }
63
+ /** Newest first — the order the file is stored in and the order callers get. */
64
+ export type JobHistory = JobHistoryEntry[];
65
+ /**
66
+ * Pruning. Both bounds apply on every write, so the file cannot grow forever:
67
+ *
68
+ * - 500 entries: a single big push is one job per file per 500-key chunk, so a
69
+ * large repo can queue dozens at once. 500 keeps weeks of real use while
70
+ * staying a ~100KB file.
71
+ * - 30 days: the server purges finished jobs after ~1h, so an entry older than
72
+ * this can never be re-checked — it is a receipt, and a month-old receipt for
73
+ * a push you have long since repeated is not worth carrying.
74
+ *
75
+ * Whichever bites first wins; entries are dropped oldest-first.
76
+ */
77
+ export declare const JOB_HISTORY_MAX_ENTRIES = 500;
78
+ export declare const JOB_HISTORY_MAX_AGE_DAYS = 30;
79
+ /**
80
+ * True when there is no point asking the server again. `failed` counts: the
81
+ * server only sets it from its dead-letter path, so a failed job is one it gave
82
+ * up on. `gone` is ours — the job 404s (unknown id, or purged after retention).
83
+ */
84
+ export declare function isTerminalJobStatus(status: string): boolean;
85
+ /** Where the history lives: `~/.lionrapid/jobs.json`, beside the credentials file. */
86
+ export declare function defaultJobHistoryPath(): string;
87
+ /**
88
+ * Read the history, newest first. A missing, unreadable, or corrupt file is
89
+ * simply "no history" — never an exception, and the next write replaces it.
90
+ */
91
+ export declare function readJobHistory(path?: string): JobHistory;
92
+ /**
93
+ * Upsert `entries` (by job id) and write the pruned result. Returns what is now
94
+ * on disk, newest first. A failed write warns and returns the merged list — a
95
+ * push must not fail because its receipt could not be saved.
96
+ */
97
+ export declare function recordJobs(entries: JobHistoryEntry[], path?: string): JobHistory;
98
+ /** A status refresh for one job: everything except its identity may change. */
99
+ export type JobStatusPatch = Pick<JobHistoryEntry, 'id'> & Partial<Pick<JobHistoryEntry, 'status' | 'checkedAt' | 'error' | 'deadLetterReason'>>;
100
+ /**
101
+ * Fold status updates into the stored entries in a single read-modify-write.
102
+ * Unknown ids are ignored (the history is not a job database — it only tracks
103
+ * what this machine pushed).
104
+ */
105
+ export declare function updateJobStatuses(patches: JobStatusPatch[], path?: string): JobHistory;
106
+ //# sourceMappingURL=job-history.d.ts.map
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Local record of the sync jobs this machine has queued — `~/.lionrapid/jobs.json`.
3
+ *
4
+ * Why a file at all: `sync/batch` answers 202 (queued), so the only proof a push
5
+ * actually landed is the job's later state. You push, close the terminal, and
6
+ * come back — without this file the job ids are gone with the scrollback and
7
+ * there is nothing left to ask the server about.
8
+ *
9
+ * Why NOT in `~/.lionrapid/credentials`: different lifetime (history is
10
+ * disposable, a key is not) and different sensitivity. A corrupt or truncated
11
+ * history must never cost someone their login, so the two files never share a
12
+ * parser or a write. Everything here degrades to "no history" instead of
13
+ * throwing — losing visibility is bad, but failing a push because a cache file
14
+ * is unreadable is worse.
15
+ *
16
+ * The server purges finished non-DLQ jobs after ~1h, so for anything older this
17
+ * file is the ONLY answer available: a terminal status is recorded when we see
18
+ * it, and later reads report it from disk without a network call.
19
+ */
20
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+ import { homeDir } from './config.js';
23
+ /**
24
+ * Pruning. Both bounds apply on every write, so the file cannot grow forever:
25
+ *
26
+ * - 500 entries: a single big push is one job per file per 500-key chunk, so a
27
+ * large repo can queue dozens at once. 500 keeps weeks of real use while
28
+ * staying a ~100KB file.
29
+ * - 30 days: the server purges finished jobs after ~1h, so an entry older than
30
+ * this can never be re-checked — it is a receipt, and a month-old receipt for
31
+ * a push you have long since repeated is not worth carrying.
32
+ *
33
+ * Whichever bites first wins; entries are dropped oldest-first.
34
+ */
35
+ export const JOB_HISTORY_MAX_ENTRIES = 500;
36
+ export const JOB_HISTORY_MAX_AGE_DAYS = 30;
37
+ /** Statuses the server never leaves, plus the CLI's own `gone`. */
38
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'gone']);
39
+ /**
40
+ * True when there is no point asking the server again. `failed` counts: the
41
+ * server only sets it from its dead-letter path, so a failed job is one it gave
42
+ * up on. `gone` is ours — the job 404s (unknown id, or purged after retention).
43
+ */
44
+ export function isTerminalJobStatus(status) {
45
+ return TERMINAL_STATUSES.has(status);
46
+ }
47
+ /** Where the history lives: `~/.lionrapid/jobs.json`, beside the credentials file. */
48
+ export function defaultJobHistoryPath() {
49
+ return join(homeDir(), '.lionrapid', 'jobs.json');
50
+ }
51
+ /** Paths already warned about this process — an unreadable history warns once. */
52
+ const warned = new Set();
53
+ function warnOnce(path, detail) {
54
+ if (warned.has(path))
55
+ return;
56
+ warned.add(path);
57
+ console.error(`warning: could not use the push history at ${path} (${detail})`);
58
+ }
59
+ function isEntry(value) {
60
+ if (value === null || typeof value !== 'object')
61
+ return false;
62
+ const e = value;
63
+ return typeof e['id'] === 'string' && e['id'].length > 0 && typeof e['pushedAt'] === 'string';
64
+ }
65
+ /**
66
+ * Read the history, newest first. A missing, unreadable, or corrupt file is
67
+ * simply "no history" — never an exception, and the next write replaces it.
68
+ */
69
+ export function readJobHistory(path = defaultJobHistoryPath()) {
70
+ if (!existsSync(path))
71
+ return [];
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
75
+ }
76
+ catch (err) {
77
+ warnOnce(path, err instanceof Error ? err.message : String(err));
78
+ return [];
79
+ }
80
+ const jobs = parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.jobs)
81
+ ? parsed.jobs
82
+ : [];
83
+ return sortNewestFirst(jobs.filter(isEntry));
84
+ }
85
+ /**
86
+ * Upsert `entries` (by job id) and write the pruned result. Returns what is now
87
+ * on disk, newest first. A failed write warns and returns the merged list — a
88
+ * push must not fail because its receipt could not be saved.
89
+ */
90
+ export function recordJobs(entries, path = defaultJobHistoryPath()) {
91
+ if (entries.length === 0)
92
+ return readJobHistory(path);
93
+ return mutate(path, (current) => [...entries, ...current]);
94
+ }
95
+ /**
96
+ * Fold status updates into the stored entries in a single read-modify-write.
97
+ * Unknown ids are ignored (the history is not a job database — it only tracks
98
+ * what this machine pushed).
99
+ */
100
+ export function updateJobStatuses(patches, path = defaultJobHistoryPath()) {
101
+ if (patches.length === 0)
102
+ return readJobHistory(path);
103
+ const byId = new Map(patches.map((p) => [p.id, p]));
104
+ return mutate(path, (current) => current.map((entry) => {
105
+ const patch = byId.get(entry.id);
106
+ return patch ? { ...entry, ...stripUndefined(patch), id: entry.id } : entry;
107
+ }));
108
+ }
109
+ /** Read → transform → dedupe → prune → write. The one place that touches the file. */
110
+ function mutate(path, transform) {
111
+ // Re-read immediately before writing: two pushes in different terminals
112
+ // otherwise clobber each other's ids.
113
+ const next = prune(dedupe(transform(readJobHistory(path))));
114
+ try {
115
+ write(path, next);
116
+ }
117
+ catch (err) {
118
+ warnOnce(path, err instanceof Error ? err.message : String(err));
119
+ }
120
+ return next;
121
+ }
122
+ /** First occurrence of an id wins — callers pass their fresher entries first. */
123
+ function dedupe(entries) {
124
+ const seen = new Set();
125
+ const out = [];
126
+ for (const entry of entries) {
127
+ if (seen.has(entry.id))
128
+ continue;
129
+ seen.add(entry.id);
130
+ out.push(entry);
131
+ }
132
+ return out;
133
+ }
134
+ function prune(entries) {
135
+ const cutoff = Date.now() - JOB_HISTORY_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
136
+ const fresh = sortNewestFirst(entries).filter((e) => {
137
+ const at = Date.parse(e.pushedAt);
138
+ // An unparseable timestamp is kept: dropping a receipt because we cannot
139
+ // read its clock loses exactly the information the file exists to hold.
140
+ return Number.isNaN(at) || at >= cutoff;
141
+ });
142
+ return fresh.slice(0, JOB_HISTORY_MAX_ENTRIES);
143
+ }
144
+ function sortNewestFirst(entries) {
145
+ return [...entries].sort((a, b) => (a.pushedAt < b.pushedAt ? 1 : a.pushedAt > b.pushedAt ? -1 : 0));
146
+ }
147
+ function stripUndefined(value) {
148
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
149
+ }
150
+ /**
151
+ * Write via a temp file + rename so a crash mid-write leaves the previous
152
+ * history intact rather than a half-written one. Same 0700/0600 permissions as
153
+ * the credentials file: this records project names and file paths.
154
+ */
155
+ function write(path, entries) {
156
+ const dir = dirname(path);
157
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
158
+ const tmp = join(dir, `.jobs.${process.pid}.tmp`);
159
+ const body = `${JSON.stringify({ version: 1, jobs: entries }, null, 2)}\n`;
160
+ writeFileSync(tmp, body, { mode: 0o600 });
161
+ renameSync(tmp, path);
162
+ }
163
+ //# sourceMappingURL=job-history.js.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `.lionrapid/lock.json` — the record of what the SERVER last said.
3
+ *
4
+ * Today `push` re-uploads every string every time: 653 on the first run, 653
5
+ * on the second. And there is no way to know a translator edited a target
6
+ * locally, so `pull` would silently overwrite it. Both are the same missing
7
+ * thing — no record of the last known server state — and one mechanism buys
8
+ * incremental push, conflict detection, and a CI action that does not
9
+ * re-translate the world on every run.
10
+ *
11
+ * COMMITTED to version control, like `package-lock.json`. Per-machine would
12
+ * mean CI and a laptop disagree about what changed, which is exactly the
13
+ * disagreement an automated pipeline cannot tolerate.
14
+ *
15
+ * Two fields, two owners, and the split matters:
16
+ * `sourceHash` what we last SENT for a source key (push writes it)
17
+ * `serverUpdatedAt` when the server last told us about a row (PULL ONLY)
18
+ * Push must never fabricate `serverUpdatedAt`, or the lock would claim
19
+ * knowledge of a server state it never observed.
20
+ */
21
+ import { type ContentUnit } from '@lionrapid/core';
22
+ /** Where the lock lives, relative to the project root. */
23
+ export declare const LOCKFILE_PATH: string;
24
+ /** The only shape this CLI writes. A higher number is a newer CLI's file. */
25
+ export declare const LOCK_VERSION = 1;
26
+ export interface LockTarget {
27
+ /** Hash of the target value as the server last gave it to us. */
28
+ hash: string;
29
+ /** When the server last reported this row. Written by `pull`, never by `push`. */
30
+ serverUpdatedAt?: string;
31
+ }
32
+ export interface LockEntry {
33
+ /** Hash of the source text this key was last known to carry. */
34
+ sourceHash: string;
35
+ /** When the server last reported the source row (pull-only). */
36
+ serverUpdatedAt?: string;
37
+ /** Per-target-locale state, keyed by locale. */
38
+ targets?: Record<string, LockTarget>;
39
+ }
40
+ export interface Lock {
41
+ version: number;
42
+ /**
43
+ * The project this lock was written against. A lock from another project
44
+ * computes a nonsense diff, so a mismatch stops the run.
45
+ *
46
+ * OPTIONAL because nothing serves a project id yet — it arrives with the
47
+ * server's `projectId` on project-config (spec C4/S2). Until then locks
48
+ * carry none and the check simply has nothing to compare.
49
+ */
50
+ projectId?: string;
51
+ /** `namespace:key` → what we last knew. */
52
+ entries: Record<string, LockEntry>;
53
+ }
54
+ export interface ReadLockOptions {
55
+ /** The config's project id, when one is known, for the mismatch guard. */
56
+ projectId?: string;
57
+ }
58
+ /** A lock that knows nothing. Distinct from "everything changed" — see `readLock`. */
59
+ export declare function emptyLock(): Lock;
60
+ /**
61
+ * Hash a source value.
62
+ *
63
+ * Reuses `generateAutoKey`, the md5-12 already shared by core, edge, WordPress
64
+ * TS and WordPress PHP and pinned by `parity-vectors.json`. A second hash
65
+ * would be a second thing to keep in lockstep for no gain. Inline markup is
66
+ * part of the value, so the indexed tags travel into the hash — a bold run
67
+ * added around a word is a real change to push.
68
+ */
69
+ export declare function hashSource(value: ContentUnit): string;
70
+ /** The lock's key for a row: the coordinates a push actually addresses. */
71
+ export declare function lockKey(namespace: string, key: string): string;
72
+ /**
73
+ * Read the lock, or an empty one when there is no file.
74
+ *
75
+ * A MISSING lock is "we know nothing" (a backfill), never "everything
76
+ * changed". A CORRUPT lock throws: silently rebuilding one would mark local
77
+ * edits as server-known and lose them on the very next pull, which is the
78
+ * failure this file exists to prevent.
79
+ */
80
+ export declare function readLock(cwd: string, opts?: ReadLockOptions): Lock;
81
+ /**
82
+ * Write the lock, keys sorted.
83
+ *
84
+ * Sorted because this file is committed: a lock that reorders itself produces
85
+ * a diff on every run and stops being reviewable, which is most of the reason
86
+ * to commit it. Written via temp+rename so an interrupted run cannot leave a
87
+ * half-written lock that the next run would refuse to parse.
88
+ */
89
+ export declare function writeLock(cwd: string, lock: Lock): void;
90
+ //# sourceMappingURL=lockfile.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `.lionrapid/lock.json` — the record of what the SERVER last said.
3
+ *
4
+ * Today `push` re-uploads every string every time: 653 on the first run, 653
5
+ * on the second. And there is no way to know a translator edited a target
6
+ * locally, so `pull` would silently overwrite it. Both are the same missing
7
+ * thing — no record of the last known server state — and one mechanism buys
8
+ * incremental push, conflict detection, and a CI action that does not
9
+ * re-translate the world on every run.
10
+ *
11
+ * COMMITTED to version control, like `package-lock.json`. Per-machine would
12
+ * mean CI and a laptop disagree about what changed, which is exactly the
13
+ * disagreement an automated pipeline cannot tolerate.
14
+ *
15
+ * Two fields, two owners, and the split matters:
16
+ * `sourceHash` what we last SENT for a source key (push writes it)
17
+ * `serverUpdatedAt` when the server last told us about a row (PULL ONLY)
18
+ * Push must never fabricate `serverUpdatedAt`, or the lock would claim
19
+ * knowledge of a server state it never observed.
20
+ */
21
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { generateAutoKey } from '@lionrapid/core';
24
+ import { ConfigError } from './config.js';
25
+ /** Where the lock lives, relative to the project root. */
26
+ export const LOCKFILE_PATH = join('.lionrapid', 'lock.json');
27
+ /** The only shape this CLI writes. A higher number is a newer CLI's file. */
28
+ export const LOCK_VERSION = 1;
29
+ /** A lock that knows nothing. Distinct from "everything changed" — see `readLock`. */
30
+ export function emptyLock() {
31
+ return { version: LOCK_VERSION, entries: {} };
32
+ }
33
+ /**
34
+ * Hash a source value.
35
+ *
36
+ * Reuses `generateAutoKey`, the md5-12 already shared by core, edge, WordPress
37
+ * TS and WordPress PHP and pinned by `parity-vectors.json`. A second hash
38
+ * would be a second thing to keep in lockstep for no gain. Inline markup is
39
+ * part of the value, so the indexed tags travel into the hash — a bold run
40
+ * added around a word is a real change to push.
41
+ */
42
+ export function hashSource(value) {
43
+ const styles = Object.keys(value.styles ?? {}).length > 0 ? JSON.stringify(value.styles) : '';
44
+ return generateAutoKey(styles ? `${value.text}${styles}` : value.text);
45
+ }
46
+ /** The lock's key for a row: the coordinates a push actually addresses. */
47
+ export function lockKey(namespace, key) {
48
+ return `${namespace}:${key}`;
49
+ }
50
+ /**
51
+ * Read the lock, or an empty one when there is no file.
52
+ *
53
+ * A MISSING lock is "we know nothing" (a backfill), never "everything
54
+ * changed". A CORRUPT lock throws: silently rebuilding one would mark local
55
+ * edits as server-known and lose them on the very next pull, which is the
56
+ * failure this file exists to prevent.
57
+ */
58
+ export function readLock(cwd, opts = {}) {
59
+ const path = join(cwd, LOCKFILE_PATH);
60
+ if (!existsSync(path))
61
+ return emptyLock();
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
65
+ }
66
+ catch (err) {
67
+ throw new ConfigError(`Could not parse ${LOCKFILE_PATH}: ${err instanceof Error ? err.message : String(err)}.\n` +
68
+ ` Delete ${LOCKFILE_PATH} and run \`lionrapid pull\` to rebuild it from the server.`);
69
+ }
70
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
71
+ throw new ConfigError(`${LOCKFILE_PATH} is not a JSON object.\n` +
72
+ ` Delete ${LOCKFILE_PATH} and run \`lionrapid pull\` to rebuild it.`);
73
+ }
74
+ const lock = parsed;
75
+ if (typeof lock.version !== 'number' || lock.version > LOCK_VERSION) {
76
+ throw new ConfigError(`${LOCKFILE_PATH} is version ${String(lock.version)}, but this CLI understands ${LOCK_VERSION}.\n` +
77
+ ' Upgrade @lionrapid/cli, or delete the file and re-pull.');
78
+ }
79
+ if (opts.projectId && lock.projectId && lock.projectId !== opts.projectId) {
80
+ throw new ConfigError(`${LOCKFILE_PATH} belongs to project ${lock.projectId}, but this config is ${opts.projectId}.\n` +
81
+ ' A lock from another project would compute a meaningless diff — delete it and re-pull.');
82
+ }
83
+ return {
84
+ version: lock.version,
85
+ ...(lock.projectId ? { projectId: lock.projectId } : {}),
86
+ entries: lock.entries !== null && typeof lock.entries === 'object' && !Array.isArray(lock.entries)
87
+ ? lock.entries
88
+ : {},
89
+ };
90
+ }
91
+ /**
92
+ * Write the lock, keys sorted.
93
+ *
94
+ * Sorted because this file is committed: a lock that reorders itself produces
95
+ * a diff on every run and stops being reviewable, which is most of the reason
96
+ * to commit it. Written via temp+rename so an interrupted run cannot leave a
97
+ * half-written lock that the next run would refuse to parse.
98
+ */
99
+ export function writeLock(cwd, lock) {
100
+ const path = join(cwd, LOCKFILE_PATH);
101
+ const sorted = {};
102
+ for (const key of Object.keys(lock.entries).sort())
103
+ sorted[key] = lock.entries[key];
104
+ const body = `${JSON.stringify({ ...lock, entries: sorted }, null, 2)}\n`;
105
+ mkdirSync(dirname(path), { recursive: true });
106
+ const tmp = `${path}.${process.pid}.tmp`;
107
+ writeFileSync(tmp, body);
108
+ renameSync(tmp, path);
109
+ }
110
+ //# sourceMappingURL=lockfile.js.map
package/dist/time.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Timestamp handling for values that come off the wire.
3
+ *
4
+ * tm-server serialises with `datetime.isoformat()` and its clock is UTC, so a
5
+ * job's `completed_at` arrives as `2026-08-07T16:35:24.248993` — an ISO
6
+ * date-time with NO timezone designator. `Date.parse` reads that as LOCAL
7
+ * time, which on a UTC+3 machine rendered every server timestamp three hours
8
+ * early: `lionrapid status` showed a job finishing three hours before the push
9
+ * that created it.
10
+ *
11
+ * The rule is about the STRING, not about tm-server: no offset means UTC, an
12
+ * offset means exactly what it says. Appending `Z` unconditionally would fix
13
+ * today and break the day the server starts sending real offsets.
14
+ */
15
+ /**
16
+ * Epoch milliseconds for a server timestamp, or null if it is absent or
17
+ * unparseable. An offset-less date-time is read as UTC; anything already
18
+ * carrying a zone is passed through untouched.
19
+ */
20
+ export declare function parseServerTime(value: string | undefined | null): number | null;
21
+ /**
22
+ * `2026-08-07 19:35:24` in the reader's own timezone. A value we cannot parse
23
+ * is echoed verbatim rather than rendered as "Invalid Date" — showing the raw
24
+ * string at least tells you what the server said.
25
+ */
26
+ export declare function formatLocal(value: string | undefined | null): string;
27
+ //# sourceMappingURL=time.d.ts.map
package/dist/time.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Timestamp handling for values that come off the wire.
3
+ *
4
+ * tm-server serialises with `datetime.isoformat()` and its clock is UTC, so a
5
+ * job's `completed_at` arrives as `2026-08-07T16:35:24.248993` — an ISO
6
+ * date-time with NO timezone designator. `Date.parse` reads that as LOCAL
7
+ * time, which on a UTC+3 machine rendered every server timestamp three hours
8
+ * early: `lionrapid status` showed a job finishing three hours before the push
9
+ * that created it.
10
+ *
11
+ * The rule is about the STRING, not about tm-server: no offset means UTC, an
12
+ * offset means exactly what it says. Appending `Z` unconditionally would fix
13
+ * today and break the day the server starts sending real offsets.
14
+ */
15
+ /** True when the time part already carries `Z` or a `±HH:MM` / `±HHMM` offset. */
16
+ function hasTimezone(value) {
17
+ const timePart = value.slice(value.indexOf('T') + 1);
18
+ return /[Zz]$/.test(timePart) || /[+-]\d{2}:?\d{2}$/.test(timePart);
19
+ }
20
+ /**
21
+ * Epoch milliseconds for a server timestamp, or null if it is absent or
22
+ * unparseable. An offset-less date-time is read as UTC; anything already
23
+ * carrying a zone is passed through untouched.
24
+ */
25
+ export function parseServerTime(value) {
26
+ if (!value)
27
+ return null;
28
+ const trimmed = value.trim();
29
+ if (!trimmed)
30
+ return null;
31
+ // A date-only value has no time to misinterpret — JS already reads those as
32
+ // UTC, and appending `Z` would make it unparseable.
33
+ const naive = trimmed.includes('T') && !hasTimezone(trimmed);
34
+ const ms = Date.parse(naive ? `${trimmed}Z` : trimmed);
35
+ return Number.isNaN(ms) ? null : ms;
36
+ }
37
+ /**
38
+ * `2026-08-07 19:35:24` in the reader's own timezone. A value we cannot parse
39
+ * is echoed verbatim rather than rendered as "Invalid Date" — showing the raw
40
+ * string at least tells you what the server said.
41
+ */
42
+ export function formatLocal(value) {
43
+ if (!value)
44
+ return '—';
45
+ const ms = parseServerTime(value);
46
+ if (ms === null)
47
+ return value;
48
+ const d = new Date(ms);
49
+ const pad = (n) => String(n).padStart(2, '0');
50
+ return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
51
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`);
52
+ }
53
+ //# sourceMappingURL=time.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lionrapid/cli",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "LionRapid CLI — sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",