@lionrapid/cli 0.3.0 → 0.5.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,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,7 +1,7 @@
1
1
  {
2
2
  "name": "@lionrapid/cli",
3
- "version": "0.3.0",
4
- "description": "LionRapid CLI \u2014 sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend",
3
+ "version": "0.5.0",
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",
7
7
  "types": "./dist/index.d.ts",