@lionrapid/cli 0.2.1 → 0.3.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 +206 -0
- package/README.md +53 -4
- package/dist/adapters/json.d.ts +2 -2
- package/dist/adapters/json.js +4 -3
- package/dist/adapters/nested.d.ts +6 -0
- package/dist/adapters/nested.js +8 -0
- package/dist/adapters/types.d.ts +2 -0
- package/dist/adapters/yaml.js +3 -3
- package/dist/api-client.d.ts +151 -1
- package/dist/api-client.js +221 -8
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +448 -23
- package/dist/commands/context.d.ts +9 -0
- package/dist/commands/context.js +28 -2
- package/dist/commands/doctor.d.ts +2 -1
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.js +0 -0
- package/dist/commands/login.d.ts +47 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/pull.d.ts +22 -0
- package/dist/commands/pull.js +140 -21
- package/dist/commands/push.d.ts +36 -0
- package/dist/commands/push.js +61 -16
- package/dist/commands/snapshot.js +3 -2
- package/dist/commands/status.d.ts +76 -0
- package/dist/commands/status.js +221 -0
- package/dist/commands/types.js +2 -1
- package/dist/commands/validate.d.ts +5 -2
- package/dist/commands/validate.js +15 -2
- package/dist/config.d.ts +41 -1
- package/dist/config.js +162 -8
- package/dist/job-history.d.ts +97 -0
- package/dist/job-history.js +175 -0
- package/dist/types-gen/extract.js +7 -1
- package/dist/types-gen/generate.js +9 -1
- package/package.json +2 -2
package/dist/config.js
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* - `parseConfig(string)` is split from `loadConfig(path)` so the validator is
|
|
10
10
|
* unit-testable without touching the filesystem.
|
|
11
11
|
*/
|
|
12
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { join } from 'node:path';
|
|
14
|
+
import { basename, join } from 'node:path';
|
|
15
15
|
import { parse as parseYaml } from 'yaml';
|
|
16
|
+
import { discoverFiles } from './scan/discover.js';
|
|
16
17
|
/** Translation file formats the CLI can parse/serialize. */
|
|
17
18
|
export const FILE_FORMATS = [
|
|
18
19
|
'json',
|
|
@@ -29,6 +30,10 @@ export const FILE_FORMATS = [
|
|
|
29
30
|
* `target` path is one shared file and does not need a `{locale}` placeholder.
|
|
30
31
|
*/
|
|
31
32
|
export const MULTI_LOCALE_FORMATS = new Set(['xcstrings']);
|
|
33
|
+
/** True when a `files[].source` is a glob pattern rather than a concrete path. */
|
|
34
|
+
export function isGlobSource(source) {
|
|
35
|
+
return /[*?]/.test(source);
|
|
36
|
+
}
|
|
32
37
|
/** Thrown when `lionrapid.yml` is missing, unparseable, or invalid. */
|
|
33
38
|
export class ConfigError extends Error {
|
|
34
39
|
issues;
|
|
@@ -227,16 +232,79 @@ function validateConfig(raw) {
|
|
|
227
232
|
entryNs = entry['namespace'];
|
|
228
233
|
}
|
|
229
234
|
}
|
|
235
|
+
// flat (optional): only JSON/YAML re-nest dotted keys on serialize, so
|
|
236
|
+
// the flag is meaningless (and likely a config mistake) anywhere else.
|
|
237
|
+
let entryFlat;
|
|
238
|
+
if (entry['flat'] !== undefined) {
|
|
239
|
+
if (typeof entry['flat'] !== 'boolean') {
|
|
240
|
+
issues.push({ path: `${base}.flat`, message: 'must be a boolean when set' });
|
|
241
|
+
}
|
|
242
|
+
else if (validFormat && format !== 'json' && format !== 'yaml') {
|
|
243
|
+
issues.push({
|
|
244
|
+
path: `${base}.flat`,
|
|
245
|
+
message: `only meaningful for json/yaml (${String(format)} output is always flat)`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
entryFlat = entry['flat'];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Glob sources: {basename} must appear in target AND namespace (else the
|
|
253
|
+
// expansion is ambiguous — every match would collide); a concrete source
|
|
254
|
+
// must not use the token at all.
|
|
255
|
+
const src = entry['source'];
|
|
256
|
+
const tgt = entry['target'];
|
|
257
|
+
const glob = isNonEmptyString(src) && isGlobSource(src);
|
|
258
|
+
const srcHasBase = isNonEmptyString(src) && src.includes('{basename}');
|
|
259
|
+
const tgtHasBase = isNonEmptyString(tgt) && tgt.includes('{basename}');
|
|
260
|
+
const nsHasBase = entryNs?.includes('{basename}') ?? false;
|
|
261
|
+
if (srcHasBase) {
|
|
262
|
+
issues.push({
|
|
263
|
+
path: `${base}.source`,
|
|
264
|
+
message: 'the "{basename}" token is only valid in target/namespace',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (glob) {
|
|
268
|
+
if (isNonEmptyString(tgt) && !tgtHasBase) {
|
|
269
|
+
issues.push({
|
|
270
|
+
path: `${base}.target`,
|
|
271
|
+
message: 'must contain the "{basename}" token when source is a glob',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (!nsHasBase) {
|
|
275
|
+
issues.push({
|
|
276
|
+
path: `${base}.namespace`,
|
|
277
|
+
message: 'required for a glob source and must contain the "{basename}" token',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
if (tgtHasBase) {
|
|
283
|
+
issues.push({
|
|
284
|
+
path: `${base}.target`,
|
|
285
|
+
message: 'the "{basename}" token is only valid when source is a glob',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (nsHasBase) {
|
|
289
|
+
issues.push({
|
|
290
|
+
path: `${base}.namespace`,
|
|
291
|
+
message: 'the "{basename}" token is only valid when source is a glob',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
230
295
|
// Only push a typed entry if its required fields are sound.
|
|
231
296
|
if (validFormat &&
|
|
232
297
|
isNonEmptyString(entry['source']) &&
|
|
233
298
|
isNonEmptyString(entry['target']) &&
|
|
234
|
-
(isMultiLocale || entry['target'].includes('{locale}'))
|
|
299
|
+
(isMultiLocale || entry['target'].includes('{locale}')) &&
|
|
300
|
+
!srcHasBase &&
|
|
301
|
+
(glob ? tgtHasBase && nsHasBase : !tgtHasBase && !nsHasBase)) {
|
|
235
302
|
files.push({
|
|
236
303
|
format: format,
|
|
237
304
|
source: entry['source'],
|
|
238
305
|
target: entry['target'],
|
|
239
306
|
...(entryNs ? { namespace: entryNs } : {}),
|
|
307
|
+
...(entryFlat !== undefined ? { flat: entryFlat } : {}),
|
|
240
308
|
});
|
|
241
309
|
}
|
|
242
310
|
});
|
|
@@ -270,6 +338,41 @@ export function parseConfig(rawYaml) {
|
|
|
270
338
|
}
|
|
271
339
|
return validateConfig(parsed);
|
|
272
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Expand glob `files` entries into one concrete entry per matched file: the
|
|
343
|
+
* match's extension-less basename substitutes the `{basename}` token in
|
|
344
|
+
* `target` and `namespace`. Non-glob entries pass through untouched; matches
|
|
345
|
+
* come back sorted (deterministic expansion). A glob matching nothing warns
|
|
346
|
+
* instead of silently vanishing — a typo'd pattern must not no-op a push.
|
|
347
|
+
*
|
|
348
|
+
* Called at config load (loadOfflineContext / doctor), so every command
|
|
349
|
+
* downstream only ever sees concrete entries.
|
|
350
|
+
*/
|
|
351
|
+
export function expandGlobEntries(files, cwd, warn = (m) => console.error(m)) {
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const entry of files) {
|
|
354
|
+
if (!isGlobSource(entry.source)) {
|
|
355
|
+
out.push(entry);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const matches = discoverFiles(cwd, { include: [entry.source] });
|
|
359
|
+
if (matches.length === 0) {
|
|
360
|
+
warn(`warning: files entry "${entry.source}" matched no files`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
for (const rel of matches) {
|
|
364
|
+
const base = basename(rel).replace(/\.[^.]*$/, '');
|
|
365
|
+
out.push({
|
|
366
|
+
format: entry.format,
|
|
367
|
+
source: rel,
|
|
368
|
+
target: entry.target.replaceAll('{basename}', base),
|
|
369
|
+
namespace: (entry.namespace ?? '{basename}').replaceAll('{basename}', base),
|
|
370
|
+
...(entry.flat !== undefined ? { flat: entry.flat } : {}),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
273
376
|
/** Read + parse + validate the config at `path`. */
|
|
274
377
|
export function loadConfig(path) {
|
|
275
378
|
if (!existsSync(path)) {
|
|
@@ -277,6 +380,55 @@ export function loadConfig(path) {
|
|
|
277
380
|
}
|
|
278
381
|
return parseConfig(readFileSync(path, 'utf8'));
|
|
279
382
|
}
|
|
383
|
+
/** Where the CLI reads/writes credentials: `~/.lionrapid/credentials`. */
|
|
384
|
+
export function defaultCredentialsPath() {
|
|
385
|
+
return join(homedir(), '.lionrapid', 'credentials');
|
|
386
|
+
}
|
|
387
|
+
/** Paths already warned about this process — the perms warning fires once per file. */
|
|
388
|
+
const warnedPerms = new Set();
|
|
389
|
+
/** Warn (once per path) when the credentials file is readable by group/other. */
|
|
390
|
+
function warnIfWorldReadable(path) {
|
|
391
|
+
if (process.platform === 'win32' || warnedPerms.has(path))
|
|
392
|
+
return;
|
|
393
|
+
if ((statSync(path).mode & 0o077) !== 0) {
|
|
394
|
+
warnedPerms.add(path);
|
|
395
|
+
console.error(`warning: ${path} is readable by other users — run: chmod 600 ${path}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Reject a key that can't travel in an HTTP header (fetch encodes headers as
|
|
400
|
+
* Latin-1 and throws an obscure ByteString error otherwise). The classic case
|
|
401
|
+
* is copying the dashboard's truncated key display, `sk_eng_…last4` — that
|
|
402
|
+
* ellipsis is U+2026, not part of the key.
|
|
403
|
+
*/
|
|
404
|
+
export function assertKeyCharset(key) {
|
|
405
|
+
const bad = /[^\x21-\x7e]/.exec(key);
|
|
406
|
+
if (bad) {
|
|
407
|
+
const c = bad[0];
|
|
408
|
+
const cp = c.codePointAt(0).toString(16).padStart(4, '0').toUpperCase();
|
|
409
|
+
throw new ApiKeyError(`The API key contains a character that can't be sent in an HTTP header: '${c}' (U+${cp}). ` +
|
|
410
|
+
'This usually means a truncated display value was copied — paste the full key.');
|
|
411
|
+
}
|
|
412
|
+
return key;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Server URLs that have a scoped entry in the credentials file. Lets `init`
|
|
416
|
+
* default its server to the one you just logged into instead of asking again.
|
|
417
|
+
*/
|
|
418
|
+
export function listCredentialServers(credentialsPath) {
|
|
419
|
+
const path = credentialsPath ?? defaultCredentialsPath();
|
|
420
|
+
if (!existsSync(path))
|
|
421
|
+
return [];
|
|
422
|
+
try {
|
|
423
|
+
const creds = JSON.parse(readFileSync(path, 'utf8'));
|
|
424
|
+
if (!isRecord(creds))
|
|
425
|
+
return [];
|
|
426
|
+
return Object.keys(creds).filter((k) => k !== 'default' && isNonEmptyString(creds[k]));
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
280
432
|
/**
|
|
281
433
|
* Resolve the API key. Precedence:
|
|
282
434
|
* 1. `LIONRAPID_API_KEY` environment variable
|
|
@@ -292,9 +444,10 @@ export function resolveApiKey(opts = {}) {
|
|
|
292
444
|
const env = opts.env ?? process.env;
|
|
293
445
|
const fromEnv = env['LIONRAPID_API_KEY'];
|
|
294
446
|
if (isNonEmptyString(fromEnv))
|
|
295
|
-
return fromEnv.trim();
|
|
296
|
-
const credentialsPath = opts.credentialsPath ??
|
|
447
|
+
return assertKeyCharset(fromEnv.trim());
|
|
448
|
+
const credentialsPath = opts.credentialsPath ?? defaultCredentialsPath();
|
|
297
449
|
if (existsSync(credentialsPath)) {
|
|
450
|
+
warnIfWorldReadable(credentialsPath);
|
|
298
451
|
let creds;
|
|
299
452
|
try {
|
|
300
453
|
creds = JSON.parse(readFileSync(credentialsPath, 'utf8'));
|
|
@@ -305,11 +458,12 @@ export function resolveApiKey(opts = {}) {
|
|
|
305
458
|
if (isRecord(creds)) {
|
|
306
459
|
const scoped = opts.server ? creds[opts.server] : undefined;
|
|
307
460
|
if (isNonEmptyString(scoped))
|
|
308
|
-
return scoped.trim();
|
|
461
|
+
return assertKeyCharset(scoped.trim());
|
|
309
462
|
const fallback = creds['default'];
|
|
310
463
|
if (isNonEmptyString(fallback)) {
|
|
311
|
-
if (!opts.server || isTrustedDefaultHost(opts.server))
|
|
312
|
-
return fallback.trim();
|
|
464
|
+
if (!opts.server || isTrustedDefaultHost(opts.server)) {
|
|
465
|
+
return assertKeyCharset(fallback.trim());
|
|
466
|
+
}
|
|
313
467
|
throw new ApiKeyError(`Refusing to send the "default" credential to unrecognized host ${opts.server}. ` +
|
|
314
468
|
`Add a server-scoped key for it first: lionrapid login --server ${opts.server}`);
|
|
315
469
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
/** The `project:` label from lionrapid.yml. */
|
|
31
|
+
project: string;
|
|
32
|
+
namespace: string;
|
|
33
|
+
/** Source locale of the batch. */
|
|
34
|
+
locale: string;
|
|
35
|
+
/** Files whose keys were in this batch (one, unless a file was chunked). */
|
|
36
|
+
files: string[];
|
|
37
|
+
/** Keys in the batch. */
|
|
38
|
+
keys: number;
|
|
39
|
+
/** ISO timestamp of the push that queued it. */
|
|
40
|
+
pushedAt: string;
|
|
41
|
+
/**
|
|
42
|
+
* Last status seen: the server's own vocabulary (`pending`, `processing`,
|
|
43
|
+
* `retry_pending`, `completed`, `failed`), or `gone` when the server no
|
|
44
|
+
* longer knows the job.
|
|
45
|
+
*/
|
|
46
|
+
status: string;
|
|
47
|
+
/** ISO timestamp of the last status check against the server. */
|
|
48
|
+
checkedAt?: string;
|
|
49
|
+
/** Last error the server reported for this job. */
|
|
50
|
+
error?: string;
|
|
51
|
+
/** Why the backend gave up, when it did. */
|
|
52
|
+
deadLetterReason?: string;
|
|
53
|
+
}
|
|
54
|
+
/** Newest first — the order the file is stored in and the order callers get. */
|
|
55
|
+
export type JobHistory = JobHistoryEntry[];
|
|
56
|
+
/**
|
|
57
|
+
* Pruning. Both bounds apply on every write, so the file cannot grow forever:
|
|
58
|
+
*
|
|
59
|
+
* - 500 entries: a single big push is one job per file per 500-key chunk, so a
|
|
60
|
+
* large repo can queue dozens at once. 500 keeps weeks of real use while
|
|
61
|
+
* staying a ~100KB file.
|
|
62
|
+
* - 30 days: the server purges finished jobs after ~1h, so an entry older than
|
|
63
|
+
* this can never be re-checked — it is a receipt, and a month-old receipt for
|
|
64
|
+
* a push you have long since repeated is not worth carrying.
|
|
65
|
+
*
|
|
66
|
+
* Whichever bites first wins; entries are dropped oldest-first.
|
|
67
|
+
*/
|
|
68
|
+
export declare const JOB_HISTORY_MAX_ENTRIES = 500;
|
|
69
|
+
export declare const JOB_HISTORY_MAX_AGE_DAYS = 30;
|
|
70
|
+
/**
|
|
71
|
+
* True when there is no point asking the server again. `failed` counts: the
|
|
72
|
+
* server only sets it from its dead-letter path, so a failed job is one it gave
|
|
73
|
+
* up on. `gone` is ours — the job 404s (unknown id, or purged after retention).
|
|
74
|
+
*/
|
|
75
|
+
export declare function isTerminalJobStatus(status: string): boolean;
|
|
76
|
+
/** Where the history lives: `~/.lionrapid/jobs.json`, beside the credentials file. */
|
|
77
|
+
export declare function defaultJobHistoryPath(): string;
|
|
78
|
+
/**
|
|
79
|
+
* Read the history, newest first. A missing, unreadable, or corrupt file is
|
|
80
|
+
* simply "no history" — never an exception, and the next write replaces it.
|
|
81
|
+
*/
|
|
82
|
+
export declare function readJobHistory(path?: string): JobHistory;
|
|
83
|
+
/**
|
|
84
|
+
* Upsert `entries` (by job id) and write the pruned result. Returns what is now
|
|
85
|
+
* on disk, newest first. A failed write warns and returns the merged list — a
|
|
86
|
+
* push must not fail because its receipt could not be saved.
|
|
87
|
+
*/
|
|
88
|
+
export declare function recordJobs(entries: JobHistoryEntry[], path?: string): JobHistory;
|
|
89
|
+
/** A status refresh for one job: everything except its identity may change. */
|
|
90
|
+
export type JobStatusPatch = Pick<JobHistoryEntry, 'id'> & Partial<Pick<JobHistoryEntry, 'status' | 'checkedAt' | 'error' | 'deadLetterReason'>>;
|
|
91
|
+
/**
|
|
92
|
+
* Fold status updates into the stored entries in a single read-modify-write.
|
|
93
|
+
* Unknown ids are ignored (the history is not a job database — it only tracks
|
|
94
|
+
* what this machine pushed).
|
|
95
|
+
*/
|
|
96
|
+
export declare function updateJobStatuses(patches: JobStatusPatch[], path?: string): JobHistory;
|
|
97
|
+
//# sourceMappingURL=job-history.d.ts.map
|
|
@@ -0,0 +1,175 @@
|
|
|
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 { homedir } from 'node:os';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
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
|
+
/**
|
|
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
|
+
/** Where the history lives: `~/.lionrapid/jobs.json`, beside the credentials file. */
|
|
60
|
+
export function defaultJobHistoryPath() {
|
|
61
|
+
return join(homeDir(), '.lionrapid', 'jobs.json');
|
|
62
|
+
}
|
|
63
|
+
/** Paths already warned about this process — an unreadable history warns once. */
|
|
64
|
+
const warned = new Set();
|
|
65
|
+
function warnOnce(path, detail) {
|
|
66
|
+
if (warned.has(path))
|
|
67
|
+
return;
|
|
68
|
+
warned.add(path);
|
|
69
|
+
console.error(`warning: could not use the push history at ${path} (${detail})`);
|
|
70
|
+
}
|
|
71
|
+
function isEntry(value) {
|
|
72
|
+
if (value === null || typeof value !== 'object')
|
|
73
|
+
return false;
|
|
74
|
+
const e = value;
|
|
75
|
+
return typeof e['id'] === 'string' && e['id'].length > 0 && typeof e['pushedAt'] === 'string';
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read the history, newest first. A missing, unreadable, or corrupt file is
|
|
79
|
+
* simply "no history" — never an exception, and the next write replaces it.
|
|
80
|
+
*/
|
|
81
|
+
export function readJobHistory(path = defaultJobHistoryPath()) {
|
|
82
|
+
if (!existsSync(path))
|
|
83
|
+
return [];
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
warnOnce(path, err instanceof Error ? err.message : String(err));
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const jobs = parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.jobs)
|
|
93
|
+
? parsed.jobs
|
|
94
|
+
: [];
|
|
95
|
+
return sortNewestFirst(jobs.filter(isEntry));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Upsert `entries` (by job id) and write the pruned result. Returns what is now
|
|
99
|
+
* on disk, newest first. A failed write warns and returns the merged list — a
|
|
100
|
+
* push must not fail because its receipt could not be saved.
|
|
101
|
+
*/
|
|
102
|
+
export function recordJobs(entries, path = defaultJobHistoryPath()) {
|
|
103
|
+
if (entries.length === 0)
|
|
104
|
+
return readJobHistory(path);
|
|
105
|
+
return mutate(path, (current) => [...entries, ...current]);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Fold status updates into the stored entries in a single read-modify-write.
|
|
109
|
+
* Unknown ids are ignored (the history is not a job database — it only tracks
|
|
110
|
+
* what this machine pushed).
|
|
111
|
+
*/
|
|
112
|
+
export function updateJobStatuses(patches, path = defaultJobHistoryPath()) {
|
|
113
|
+
if (patches.length === 0)
|
|
114
|
+
return readJobHistory(path);
|
|
115
|
+
const byId = new Map(patches.map((p) => [p.id, p]));
|
|
116
|
+
return mutate(path, (current) => current.map((entry) => {
|
|
117
|
+
const patch = byId.get(entry.id);
|
|
118
|
+
return patch ? { ...entry, ...stripUndefined(patch), id: entry.id } : entry;
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
/** Read → transform → dedupe → prune → write. The one place that touches the file. */
|
|
122
|
+
function mutate(path, transform) {
|
|
123
|
+
// Re-read immediately before writing: two pushes in different terminals
|
|
124
|
+
// otherwise clobber each other's ids.
|
|
125
|
+
const next = prune(dedupe(transform(readJobHistory(path))));
|
|
126
|
+
try {
|
|
127
|
+
write(path, next);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
warnOnce(path, err instanceof Error ? err.message : String(err));
|
|
131
|
+
}
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
/** First occurrence of an id wins — callers pass their fresher entries first. */
|
|
135
|
+
function dedupe(entries) {
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (seen.has(entry.id))
|
|
140
|
+
continue;
|
|
141
|
+
seen.add(entry.id);
|
|
142
|
+
out.push(entry);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function prune(entries) {
|
|
147
|
+
const cutoff = Date.now() - JOB_HISTORY_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
148
|
+
const fresh = sortNewestFirst(entries).filter((e) => {
|
|
149
|
+
const at = Date.parse(e.pushedAt);
|
|
150
|
+
// An unparseable timestamp is kept: dropping a receipt because we cannot
|
|
151
|
+
// read its clock loses exactly the information the file exists to hold.
|
|
152
|
+
return Number.isNaN(at) || at >= cutoff;
|
|
153
|
+
});
|
|
154
|
+
return fresh.slice(0, JOB_HISTORY_MAX_ENTRIES);
|
|
155
|
+
}
|
|
156
|
+
function sortNewestFirst(entries) {
|
|
157
|
+
return [...entries].sort((a, b) => (a.pushedAt < b.pushedAt ? 1 : a.pushedAt > b.pushedAt ? -1 : 0));
|
|
158
|
+
}
|
|
159
|
+
function stripUndefined(value) {
|
|
160
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Write via a temp file + rename so a crash mid-write leaves the previous
|
|
164
|
+
* history intact rather than a half-written one. Same 0700/0600 permissions as
|
|
165
|
+
* the credentials file: this records project names and file paths.
|
|
166
|
+
*/
|
|
167
|
+
function write(path, entries) {
|
|
168
|
+
const dir = dirname(path);
|
|
169
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
170
|
+
const tmp = join(dir, `.jobs.${process.pid}.tmp`);
|
|
171
|
+
const body = `${JSON.stringify({ version: 1, jobs: entries }, null, 2)}\n`;
|
|
172
|
+
writeFileSync(tmp, body, { mode: 0o600 });
|
|
173
|
+
renameSync(tmp, path);
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=job-history.js.map
|
|
@@ -52,6 +52,12 @@ function readBlock(text, open) {
|
|
|
52
52
|
}
|
|
53
53
|
return { body: text.slice(open + 1), end: text.length };
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Only identifier-shaped labels may reach type position — with `types --server`
|
|
57
|
+
* the message text is server-controlled, and anything looser could break out of
|
|
58
|
+
* the `'label'` literal in the generated .d.ts.
|
|
59
|
+
*/
|
|
60
|
+
const SAFE_LABEL_RE = /^[A-Za-z0-9_]+$/;
|
|
55
61
|
/** Extract top-level select option labels (excluding `other`). */
|
|
56
62
|
function selectOptions(body) {
|
|
57
63
|
// body looks like: `gender, select, male{…} female{…} other{…}` — strip the
|
|
@@ -69,7 +75,7 @@ function selectOptions(body) {
|
|
|
69
75
|
while (i < rest.length && /\s/.test(rest[i]))
|
|
70
76
|
i += 1;
|
|
71
77
|
if (label && rest[i] === '{') {
|
|
72
|
-
if (label !== 'other')
|
|
78
|
+
if (label !== 'other' && SAFE_LABEL_RE.test(label))
|
|
73
79
|
options.push(label);
|
|
74
80
|
let depth = 1;
|
|
75
81
|
i += 1;
|
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { extractParams } from './extract.js';
|
|
7
7
|
function escapeKey(key) {
|
|
8
|
-
return key
|
|
8
|
+
return (key
|
|
9
|
+
.replace(/\\/g, '\\\\')
|
|
10
|
+
.replace(/'/g, "\\'")
|
|
11
|
+
// Control chars and JS line terminators (incl. U+2028/29) would break out
|
|
12
|
+
// of the quoted key — keys are server-controlled under `types --server`.
|
|
13
|
+
// eslint-disable-next-line no-control-regex
|
|
14
|
+
.replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, (c) => {
|
|
15
|
+
return `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
16
|
+
}));
|
|
9
17
|
}
|
|
10
18
|
export function generateDts(keyTexts, options = {}) {
|
|
11
19
|
const keys = [...keyTexts.keys()].sort();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lionrapid/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "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",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|