@lionrapid/cli 0.3.0 → 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.
package/dist/config.js CHANGED
@@ -72,6 +72,42 @@ function isTrustedDefaultHost(server) {
72
72
  const host = url.hostname;
73
73
  return host === 'lionrapid.com' || host.endsWith('.lionrapid.com') || LOCAL_HOSTS.has(host);
74
74
  }
75
+ /** Opt-in for sending a key to a host we do not recognize (self-hosted). */
76
+ export const ALLOW_UNTRUSTED_HOST_ENV = 'LIONRAPID_ALLOW_UNTRUSTED_HOST';
77
+ /**
78
+ * True only for an explicit `1`/`true`. A flag someone set to `0` or `false`
79
+ * to turn the behaviour OFF must never turn it on — "any non-empty value"
80
+ * would do exactly that.
81
+ */
82
+ function allowsUntrustedHost(env) {
83
+ const raw = env[ALLOW_UNTRUSTED_HOST_ENV]?.trim().toLowerCase();
84
+ return raw === '1' || raw === 'true';
85
+ }
86
+ /**
87
+ * Hold `LIONRAPID_API_KEY` to the same trust boundary as the credentials-file
88
+ * `default`.
89
+ *
90
+ * The env path used to return before any host check, on the reasoning that an
91
+ * operator who exports a secret has vetted where it goes (PLAN 19, 34ae81a:
92
+ * "CI sets it deliberately"). That reasoning fails in the very place it was
93
+ * written for. In CI the two halves are set by DIFFERENT PEOPLE: the operator
94
+ * injects the secret once, while `server:` lives in a committed lionrapid.yml
95
+ * that can arrive by pull request. Setting a key deliberately is not the same
96
+ * as approving its destination, and a valid key sent to an attacker is not
97
+ * self-correcting the way a wrong key is (that just 401s).
98
+ *
99
+ * No server yet (an invalid config, so `doctor` has nothing to match on) means
100
+ * there is no host to distrust — allow it, and let the config check report the
101
+ * real problem.
102
+ */
103
+ function assertEnvKeyDestination(server, env) {
104
+ if (!server || isTrustedDefaultHost(server) || allowsUntrustedHost(env))
105
+ return;
106
+ throw new ApiKeyError(`Refusing to send LIONRAPID_API_KEY to ${server} — not a recognized LionRapid host.\n` +
107
+ ' `server:` comes from lionrapid.yml, which can change by pull request, so an\n' +
108
+ ' unrecognized host means your key travels wherever that file says.\n' +
109
+ ` If this is your own self-hosted server, set ${ALLOW_UNTRUSTED_HOST_ENV}=1.`);
110
+ }
75
111
  /** Validate the `server` config value; returns an issue message or null if OK. */
76
112
  function validateServerUrl(server) {
77
113
  let url;
@@ -79,7 +115,7 @@ function validateServerUrl(server) {
79
115
  url = new URL(server);
80
116
  }
81
117
  catch {
82
- return 'must be a valid https:// URL (e.g. https://api.lionrapid.com)';
118
+ return 'must be a valid https:// URL (e.g. https://app.lionrapid.com)';
83
119
  }
84
120
  if (url.protocol === 'https:')
85
121
  return null;
@@ -102,16 +138,35 @@ function isRecord(value) {
102
138
  function isNonEmptyString(value) {
103
139
  return typeof value === 'string' && value.trim().length > 0;
104
140
  }
141
+ /**
142
+ * What a source-only config costs you. Named commands, not a vague "no
143
+ * targets": the point is that nothing is broken — two commands simply have
144
+ * nothing to do until the project has target languages.
145
+ */
146
+ export const NO_TARGETS_WARNING = 'note: no targetLocales in lionrapid.yml — `push` still uploads your source strings,\n' +
147
+ ' but `pull` and `push --target` have nothing to do until this project has target\n' +
148
+ ' languages. Add them in the dashboard, then re-run `lionrapid init`.';
149
+ /**
150
+ * The warning, but only for the commands it applies to.
151
+ *
152
+ * Emitting it at parse time meant every command carried it — including
153
+ * `status`, which reports what the backend did with a job and has nothing to
154
+ * do with target locales. A warning that fires where it does not apply is how
155
+ * people learn to ignore warnings, so the three commands that are actually
156
+ * affected ask for it and nobody else sees it.
157
+ */
158
+ export function noTargetsNotice(config) {
159
+ return config.targetLocales.length === 0 ? NO_TARGETS_WARNING : undefined;
160
+ }
105
161
  /** Validate a raw parsed object into a typed config, collecting all issues. */
106
162
  function validateConfig(raw) {
107
163
  const issues = [];
108
164
  if (!isRecord(raw)) {
109
165
  throw new ConfigError('Config must be a YAML mapping (key: value pairs).');
110
166
  }
111
- // Scalars
112
- if (!isNonEmptyString(raw['project'])) {
113
- issues.push({ path: 'project', message: 'required non-empty string' });
114
- }
167
+ // Scalars. NOTE: `project` is deliberately unread — the old free-text
168
+ // label is gone, and a config that still carries one simply ignores it
169
+ // rather than failing (pre-customer, but a stale field is not an error).
115
170
  if (!isNonEmptyString(raw['server'])) {
116
171
  issues.push({ path: 'server', message: 'required non-empty string' });
117
172
  }
@@ -123,16 +178,29 @@ function validateConfig(raw) {
123
178
  if (!isNonEmptyString(raw['sourceLocale'])) {
124
179
  issues.push({ path: 'sourceLocale', message: 'required non-empty string' });
125
180
  }
126
- // targetLocales
181
+ if (raw['projectId'] !== undefined && !isNonEmptyString(raw['projectId'])) {
182
+ issues.push({ path: 'projectId', message: 'must be a non-empty string when set' });
183
+ }
184
+ // targetLocales — OPTIONAL. `push` sends source rows and the server decides
185
+ // what to translate them into, so a config that only declares a source is a
186
+ // complete, working config. Requiring targets made `init`'s own output
187
+ // invalid (it writes `targetLocales: []` whenever it cannot reach the
188
+ // server), and an invalid config makes `doctor` blame the API key instead.
189
+ //
190
+ // Absent and empty both warn; a wrong TYPE is still an error, because that
191
+ // is a typo rather than a choice.
192
+ // An absent or empty list is simply an empty list — `noTargetsNotice` turns
193
+ // that into a message for the commands it concerns.
127
194
  const targetLocales = [];
128
- if (!Array.isArray(raw['targetLocales']) || raw['targetLocales'].length === 0) {
195
+ const rawTargets = raw['targetLocales'];
196
+ if (rawTargets !== undefined && rawTargets !== null && !Array.isArray(rawTargets)) {
129
197
  issues.push({
130
198
  path: 'targetLocales',
131
- message: 'required non-empty array of locale codes',
199
+ message: 'must be an array of locale codes when set',
132
200
  });
133
201
  }
134
- else {
135
- raw['targetLocales'].forEach((loc, i) => {
202
+ else if (Array.isArray(rawTargets) && rawTargets.length > 0) {
203
+ rawTargets.forEach((loc, i) => {
136
204
  if (!isNonEmptyString(loc)) {
137
205
  issues.push({ path: `targetLocales[${i}]`, message: 'must be a non-empty string' });
138
206
  }
@@ -313,7 +381,7 @@ function validateConfig(raw) {
313
381
  throw new ConfigError('Invalid lionrapid config:', issues);
314
382
  }
315
383
  return {
316
- project: raw['project'],
384
+ ...(isNonEmptyString(raw['projectId']) ? { projectId: raw['projectId'] } : {}),
317
385
  server: raw['server'].replace(/\/$/, ''),
318
386
  sourceLocale: raw['sourceLocale'],
319
387
  targetLocales,
@@ -380,9 +448,24 @@ export function loadConfig(path) {
380
448
  }
381
449
  return parseConfig(readFileSync(path, 'utf8'));
382
450
  }
451
+ /**
452
+ * The user's home directory, `$HOME` first.
453
+ *
454
+ * Identical to `os.homedir()` for real users on POSIX (it reads `$HOME` too),
455
+ * but a JS-level `process.env.HOME` assignment is invisible to `homedir()`'s
456
+ * native `getenv` inside a worker thread — which is where vitest runs. Without
457
+ * this, the suite reads (and could write) the developer's real
458
+ * `~/.lionrapid/`: a test asserting "no credentials" passes in CI and fails on
459
+ * a laptop that has logged in, which is a silent source of divergence.
460
+ *
461
+ * Shared by {@link defaultCredentialsPath} and the job history.
462
+ */
463
+ export function homeDir() {
464
+ return process.env['HOME'] || process.env['USERPROFILE'] || homedir();
465
+ }
383
466
  /** Where the CLI reads/writes credentials: `~/.lionrapid/credentials`. */
384
467
  export function defaultCredentialsPath() {
385
- return join(homedir(), '.lionrapid', 'credentials');
468
+ return join(homeDir(), '.lionrapid', 'credentials');
386
469
  }
387
470
  /** Paths already warned about this process — the perms warning fires once per file. */
388
471
  const warnedPerms = new Set();
@@ -435,16 +518,34 @@ export function listCredentialServers(credentialsPath) {
435
518
  * 2. credentials file (JSON: `{ "default": "sk_...", "<server>": "sk_..." }`)
436
519
  * The key is never read from `lionrapid.yml` (which is committed to git).
437
520
  *
438
- * Trust rule: the `default` credential is only handed out for recognized
439
- * LionRapid hosts (`*.lionrapid.com`) and localhost. Any other server needs
440
- * its own server-scoped entry — otherwise a committed lionrapid.yml pointing
441
- * at an attacker host would exfiltrate the key of anyone running `push`.
521
+ * Trust rule: a key is only handed out for recognized LionRapid hosts
522
+ * (`*.lionrapid.com`) and localhost. Any other server needs either its own
523
+ * server-scoped credentials entry or {@link ALLOW_UNTRUSTED_HOST_ENV} —
524
+ * otherwise a committed lionrapid.yml pointing at an attacker host would
525
+ * exfiltrate the key of anyone running `push`.
442
526
  */
443
527
  export function resolveApiKey(opts = {}) {
528
+ return resolveApiKeyDetailed(opts).key;
529
+ }
530
+ /**
531
+ * {@link resolveApiKey}, plus WHERE the key came from.
532
+ *
533
+ * One implementation, two views: `whoami` has to say which credential is about
534
+ * to be used, and a second copy of this precedence ladder would be a place for
535
+ * the two to drift — the displayed key would stop being the sent key, which is
536
+ * the exact confusion `whoami` exists to end.
537
+ */
538
+ export function resolveApiKeyDetailed(opts = {}) {
444
539
  const env = opts.env ?? process.env;
445
540
  const fromEnv = env['LIONRAPID_API_KEY'];
446
- if (isNonEmptyString(fromEnv))
447
- return assertKeyCharset(fromEnv.trim());
541
+ if (isNonEmptyString(fromEnv)) {
542
+ assertEnvKeyDestination(opts.server, env);
543
+ return {
544
+ key: assertKeyCharset(fromEnv.trim()),
545
+ source: 'env',
546
+ scope: 'LIONRAPID_API_KEY',
547
+ };
548
+ }
448
549
  const credentialsPath = opts.credentialsPath ?? defaultCredentialsPath();
449
550
  if (existsSync(credentialsPath)) {
450
551
  warnIfWorldReadable(credentialsPath);
@@ -457,18 +558,134 @@ export function resolveApiKey(opts = {}) {
457
558
  }
458
559
  if (isRecord(creds)) {
459
560
  const scoped = opts.server ? creds[opts.server] : undefined;
460
- if (isNonEmptyString(scoped))
461
- return assertKeyCharset(scoped.trim());
561
+ if (isNonEmptyString(scoped)) {
562
+ return {
563
+ key: assertKeyCharset(scoped.trim()),
564
+ source: 'server-scoped',
565
+ scope: opts.server,
566
+ };
567
+ }
462
568
  const fallback = creds['default'];
463
569
  if (isNonEmptyString(fallback)) {
464
570
  if (!opts.server || isTrustedDefaultHost(opts.server)) {
465
- return assertKeyCharset(fallback.trim());
571
+ return { key: assertKeyCharset(fallback.trim()), source: 'default', scope: 'default' };
466
572
  }
467
573
  throw new ApiKeyError(`Refusing to send the "default" credential to unrecognized host ${opts.server}. ` +
468
574
  `Add a server-scoped key for it first: lionrapid login --server ${opts.server}`);
469
575
  }
470
576
  }
471
577
  }
472
- throw new ApiKeyError('No API key found. Set LIONRAPID_API_KEY or add it to ~/.lionrapid/credentials.');
578
+ throw new ApiKeyError(noKeyMessage(opts.server, credentialsPath));
579
+ }
580
+ /**
581
+ * Display form of a key: prefix + last four, never the middle.
582
+ *
583
+ * A key shorter than the prefix+last4 window would otherwise be echoed almost
584
+ * whole, so a short (malformed, truncated) value drops the prefix entirely —
585
+ * "mask" must not become "print" for the one input where it matters.
586
+ */
587
+ export function maskKey(key) {
588
+ return maskKeyImpl(key);
589
+ }
590
+ /**
591
+ * A key's class, from its prefix.
592
+ *
593
+ * The functional line is `targets:write`, NOT the queue scopes. A `pk_` holder
594
+ * can push sources, pull and read status, but `push --target` dies on a bare
595
+ * 403 from `targets/batch` — hit for real on 2026-08-07 while trying to
596
+ * publish a row, and exactly the kind of thing worth saying BEFORE the request
597
+ * instead of after it.
598
+ */
599
+ export function keyCapability(key) {
600
+ if (key.startsWith('pk_')) {
601
+ return {
602
+ kind: 'publishable',
603
+ summary: 'publishable — pushes sources, pulls, reads status. CANNOT write or publish targets.',
604
+ canWriteTargets: false,
605
+ };
606
+ }
607
+ if (key.startsWith('sk_')) {
608
+ return {
609
+ kind: 'master',
610
+ summary: 'master — everything, including `push --target` and `--publish`.',
611
+ canWriteTargets: true,
612
+ };
613
+ }
614
+ if (key.startsWith('es_')) {
615
+ return {
616
+ kind: 'editor-session',
617
+ summary: 'editor session — writes and publishes targets, but expires within minutes.',
618
+ canWriteTargets: true,
619
+ };
620
+ }
621
+ return { kind: 'unknown', summary: 'unrecognized key prefix', canWriteTargets: false };
622
+ }
623
+ function maskKeyImpl(key) {
624
+ const trimmed = key.trim();
625
+ const last4 = trimmed.slice(-4);
626
+ return trimmed.length >= 12 ? `${trimmed.slice(0, 3)}…${last4}` : `…${last4}`;
627
+ }
628
+ /** Every saved credential, as `{ scope, masked }`. Never returns key material. */
629
+ export function listCredentials(credentialsPath = defaultCredentialsPath()) {
630
+ if (!existsSync(credentialsPath))
631
+ return [];
632
+ try {
633
+ const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'));
634
+ if (!isRecord(creds))
635
+ return [];
636
+ return Object.entries(creds)
637
+ .filter(([, value]) => isNonEmptyString(value))
638
+ .map(([scope, value]) => ({ scope, masked: maskKey(value) }));
639
+ }
640
+ catch {
641
+ return [];
642
+ }
643
+ }
644
+ /**
645
+ * The one dishonest message in the system, rewritten.
646
+ *
647
+ * "No API key found. Set LIONRAPID_API_KEY or add it to ~/.lionrapid/
648
+ * credentials." was routinely FALSE: keys are stored per server URL, so a key
649
+ * that is present and correct produces this error the moment `server:` points
650
+ * somewhere else — which is exactly what a dead default host caused. It named
651
+ * a missing credential when the real problem was a mismatched server, and cost
652
+ * an hour of hunting through a credentials file that was fine.
653
+ *
654
+ * So: name the server we wanted, list the scopes that DO exist, and give the
655
+ * command that fixes it. The sibling untrusted-host throw a few lines up
656
+ * already read like this — same file, same author, opposite outcomes.
657
+ */
658
+ function noKeyMessage(server, credentialsPath) {
659
+ const saved = listCredentialServers(credentialsPath);
660
+ const hasDefault = hasDefaultCredential(credentialsPath);
661
+ const scopes = [...(hasDefault ? ['default'] : []), ...saved];
662
+ if (scopes.length === 0) {
663
+ return (`No API key${server ? ` for ${server}` : ''}, and no keys saved yet.\n` +
664
+ ` Run: lionrapid login${server ? ` --server ${server}` : ''}\n` +
665
+ ' (or set LIONRAPID_API_KEY for a one-off / CI run.)');
666
+ }
667
+ const list = scopes.map((s) => ` · ${s}`).join('\n');
668
+ if (!server) {
669
+ // doctor's case: the config did not parse, so there is no server to match
670
+ // against. Do not invent one — say which server is in question is unknown.
671
+ return ('No API key could be resolved, because it is not clear which server to use.\n' +
672
+ `Keys are saved per server. You have:\n${list}\n` +
673
+ ' Fix `server:` in lionrapid.yml, or set LIONRAPID_API_KEY.');
674
+ }
675
+ return (`No API key for ${server}.\n` +
676
+ `Keys are saved per server. You have:\n${list}\n` +
677
+ ` Fix \`server:\` in lionrapid.yml, or: lionrapid login --server ${server}`);
678
+ }
679
+ /** True when the credentials file carries a `default` entry. */
680
+ function hasDefaultCredential(credentialsPath) {
681
+ if (!existsSync(credentialsPath))
682
+ return false;
683
+ try {
684
+ const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'));
685
+ return isRecord(creds) && isNonEmptyString(creds['default']);
686
+ }
687
+ catch {
688
+ return false;
689
+ }
473
690
  }
474
691
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `.lionrapid/history/` — a local copy of whatever a `--force` is about to
3
+ * destroy, in either direction.
4
+ *
5
+ * The server versions target rows at STAGE BOUNDARIES, not per edit: an
6
+ * ordinary edit overwrites `targetrow.target` in place. So until per-edit
7
+ * history lands server-side, the only thing standing between `--force` and a
8
+ * lost translation is this directory. Writing it makes `--force` safe TODAY,
9
+ * symmetrically:
10
+ *
11
+ * pull --force → save the LOCAL value, then take the server's
12
+ * push --force → save the SERVER's value, then overwrite it
13
+ *
14
+ * GITIGNORED, unlike the lock. The lock is a team fact and belongs in review;
15
+ * this is a per-machine safety net that may hold a translator's unfinished
16
+ * work. The CLI writes `.lionrapid/.gitignore` itself so the distinction
17
+ * survives a fresh clone without anyone having to know about it.
18
+ */
19
+ import type { ContentUnit } from '@lionrapid/core';
20
+ /** Where snapshots live, relative to the project root. */
21
+ export declare const HISTORY_DIR: string;
22
+ export interface HistoryEntry {
23
+ key: string;
24
+ value: ContentUnit;
25
+ }
26
+ export interface SaveHistoryOptions {
27
+ /** Which side was about to lose data. */
28
+ direction: 'push' | 'pull';
29
+ namespace: string;
30
+ locale: string;
31
+ /** Why this snapshot exists, in the words the user will read. */
32
+ reason: string;
33
+ entries: HistoryEntry[];
34
+ /** Timestamp, injectable so tests are deterministic. */
35
+ at?: string;
36
+ }
37
+ /**
38
+ * Write a snapshot and return its path, or `undefined` when there was nothing
39
+ * to save. Filenames carry a counter as well as a timestamp: two forces in the
40
+ * same second must not have the second one silently replace the first, which
41
+ * would defeat the point.
42
+ */
43
+ export declare function saveHistory(cwd: string, opts: SaveHistoryOptions): string | undefined;
44
+ //# sourceMappingURL=history.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `.lionrapid/history/` — a local copy of whatever a `--force` is about to
3
+ * destroy, in either direction.
4
+ *
5
+ * The server versions target rows at STAGE BOUNDARIES, not per edit: an
6
+ * ordinary edit overwrites `targetrow.target` in place. So until per-edit
7
+ * history lands server-side, the only thing standing between `--force` and a
8
+ * lost translation is this directory. Writing it makes `--force` safe TODAY,
9
+ * symmetrically:
10
+ *
11
+ * pull --force → save the LOCAL value, then take the server's
12
+ * push --force → save the SERVER's value, then overwrite it
13
+ *
14
+ * GITIGNORED, unlike the lock. The lock is a team fact and belongs in review;
15
+ * this is a per-machine safety net that may hold a translator's unfinished
16
+ * work. The CLI writes `.lionrapid/.gitignore` itself so the distinction
17
+ * survives a fresh clone without anyone having to know about it.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ /** Where snapshots live, relative to the project root. */
22
+ export const HISTORY_DIR = join('.lionrapid', 'history');
23
+ /**
24
+ * Write a snapshot and return its path, or `undefined` when there was nothing
25
+ * to save. Filenames carry a counter as well as a timestamp: two forces in the
26
+ * same second must not have the second one silently replace the first, which
27
+ * would defeat the point.
28
+ */
29
+ export function saveHistory(cwd, opts) {
30
+ if (opts.entries.length === 0)
31
+ return undefined;
32
+ const dir = join(cwd, HISTORY_DIR);
33
+ mkdirSync(dir, { recursive: true });
34
+ ensureGitignore(cwd);
35
+ const savedAt = opts.at ?? new Date().toISOString();
36
+ const stamp = savedAt.replace(/[:.]/g, '-');
37
+ const base = `${stamp}-${opts.direction}-${opts.namespace}-${opts.locale}`;
38
+ let path = join(dir, `${base}.json`);
39
+ for (let n = 2; existsSync(path); n += 1)
40
+ path = join(dir, `${base}.${n}.json`);
41
+ const entries = {};
42
+ for (const entry of opts.entries)
43
+ entries[entry.key] = entry.value;
44
+ writeFileSync(path, `${JSON.stringify({
45
+ savedAt,
46
+ direction: opts.direction,
47
+ namespace: opts.namespace,
48
+ locale: opts.locale,
49
+ reason: opts.reason,
50
+ entries,
51
+ }, null, 2)}\n`);
52
+ return path;
53
+ }
54
+ /** Keep `history/` out of git while leaving `lock.json` committed. */
55
+ function ensureGitignore(cwd) {
56
+ const path = join(cwd, '.lionrapid', '.gitignore');
57
+ const line = 'history/';
58
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
59
+ if (existing.split('\n').includes(line))
60
+ return;
61
+ writeFileSync(path, existing
62
+ ? `${existing.replace(/\n?$/, '\n')}${line}\n`
63
+ : `# Local safety copies written before a --force. Not a team fact.\n${line}\n`);
64
+ }
65
+ //# sourceMappingURL=history.js.map
@@ -27,8 +27,17 @@ export interface JobHistoryEntry {
27
27
  id: string;
28
28
  /** Server this job was queued on (a machine may push to more than one). */
29
29
  server: string;
30
- /** The `project:` label from lionrapid.yml. */
31
- project: 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;
32
41
  namespace: string;
33
42
  /** Source locale of the batch. */
34
43
  locale: string;
@@ -18,8 +18,8 @@
18
18
  * it, and later reads report it from disk without a network call.
19
19
  */
20
20
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
- import { homedir } from 'node:os';
22
21
  import { dirname, join } from 'node:path';
22
+ import { homeDir } from './config.js';
23
23
  /**
24
24
  * Pruning. Both bounds apply on every write, so the file cannot grow forever:
25
25
  *
@@ -44,18 +44,6 @@ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'gone']);
44
44
  export function isTerminalJobStatus(status) {
45
45
  return TERMINAL_STATUSES.has(status);
46
46
  }
47
- /**
48
- * The user's home directory, `$HOME` first.
49
- *
50
- * Identical to `os.homedir()` for real users on POSIX (it reads `$HOME` too),
51
- * but a JS-level `process.env.HOME` assignment is invisible to `homedir()`'s
52
- * native `getenv` inside a worker thread — which is where vitest runs. Reading
53
- * the variable directly means a relocated `$HOME` is honoured everywhere, so
54
- * the test suite cannot write into the developer's real `~/.lionrapid`.
55
- */
56
- function homeDir() {
57
- return process.env['HOME'] || process.env['USERPROFILE'] || homedir();
58
- }
59
47
  /** Where the history lives: `~/.lionrapid/jobs.json`, beside the credentials file. */
60
48
  export function defaultJobHistoryPath() {
61
49
  return join(homeDir(), '.lionrapid', 'jobs.json');
@@ -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