@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.
- package/CHANGELOG.md +128 -2
- package/dist/api-client.d.ts +79 -1
- package/dist/api-client.js +60 -1
- package/dist/cli.js +294 -45
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +47 -5
- package/dist/commands/init.d.ts +73 -2
- package/dist/commands/init.js +0 -0
- package/dist/commands/login.d.ts +50 -4
- package/dist/commands/login.js +70 -16
- package/dist/commands/pull.d.ts +15 -1
- package/dist/commands/pull.js +92 -4
- package/dist/commands/push.d.ts +62 -1
- package/dist/commands/push.js +198 -5
- package/dist/commands/status.d.ts +15 -0
- package/dist/commands/status.js +16 -5
- package/dist/commands/whoami.d.ts +50 -0
- package/dist/commands/whoami.js +66 -0
- package/dist/config.d.ts +100 -5
- package/dist/config.js +239 -22
- package/dist/history.d.ts +44 -0
- package/dist/history.js +65 -0
- package/dist/job-history.d.ts +11 -2
- package/dist/job-history.js +1 -13
- package/dist/lockfile.d.ts +90 -0
- package/dist/lockfile.js +110 -0
- package/dist/time.d.ts +27 -0
- package/dist/time.js +53 -0
- package/package.json +2 -2
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://
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
const rawTargets = raw['targetLocales'];
|
|
196
|
+
if (rawTargets !== undefined && rawTargets !== null && !Array.isArray(rawTargets)) {
|
|
129
197
|
issues.push({
|
|
130
198
|
path: 'targetLocales',
|
|
131
|
-
message: '
|
|
199
|
+
message: 'must be an array of locale codes when set',
|
|
132
200
|
});
|
|
133
201
|
}
|
|
134
|
-
else {
|
|
135
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* at an attacker host would
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
package/dist/history.js
ADDED
|
@@ -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
|
package/dist/job-history.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
|
|
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;
|
package/dist/job-history.js
CHANGED
|
@@ -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
|