@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
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* turned into a failed check.
|
|
6
6
|
*
|
|
7
7
|
* Checks: config found + valid → API key resolvable → server reachable + the
|
|
8
|
-
* token works (
|
|
8
|
+
* token works (config:read probe — pk_-compatible) → declared source files
|
|
9
|
+
* present.
|
|
9
10
|
*/
|
|
10
11
|
import { type FetchImpl } from '../api-client.js';
|
|
11
12
|
export interface DoctorCheck {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* turned into a failed check.
|
|
6
6
|
*
|
|
7
7
|
* Checks: config found + valid → API key resolvable → server reachable + the
|
|
8
|
-
* token works (
|
|
8
|
+
* token works (config:read probe — pk_-compatible) → declared source files
|
|
9
|
+
* present.
|
|
9
10
|
*/
|
|
10
11
|
import { existsSync } from 'node:fs';
|
|
11
12
|
import { resolve } from 'node:path';
|
|
12
|
-
import { findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
|
|
13
|
+
import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
|
|
13
14
|
import { LionRapidApiClient } from '../api-client.js';
|
|
14
15
|
function msg(err) {
|
|
15
16
|
return err instanceof Error ? err.message.split('\n')[0] : String(err);
|
|
@@ -29,6 +30,7 @@ export async function runDoctor(opts) {
|
|
|
29
30
|
}
|
|
30
31
|
else {
|
|
31
32
|
config = loadConfig(path);
|
|
33
|
+
config = { ...config, files: expandGlobEntries(config.files, opts.cwd) };
|
|
32
34
|
checks.push({
|
|
33
35
|
name: 'config',
|
|
34
36
|
ok: true,
|
|
@@ -57,11 +59,13 @@ export async function runDoctor(opts) {
|
|
|
57
59
|
retries: 0,
|
|
58
60
|
fetchImpl: opts.fetchImpl,
|
|
59
61
|
});
|
|
60
|
-
|
|
62
|
+
// config:read probe — works for every key class incl. publishable pk_
|
|
63
|
+
// (editor/workflow needs targets:write and would fail pk_ keys).
|
|
64
|
+
const pc = await client.getProjectConfig();
|
|
61
65
|
checks.push({
|
|
62
66
|
name: 'server',
|
|
63
67
|
ok: true,
|
|
64
|
-
detail: `${config.server} reachable · ${
|
|
68
|
+
detail: `${config.server} reachable · project locales: ${pc.defaultLocale || '?'} → ${pc.languages.filter((l) => l !== pc.defaultLocale).join(', ') || '?'}`,
|
|
65
69
|
});
|
|
66
70
|
}
|
|
67
71
|
catch (err) {
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -8,10 +8,42 @@
|
|
|
8
8
|
* action only supplies overrides and prints next steps.
|
|
9
9
|
*/
|
|
10
10
|
import type { FileFormat } from '../config.js';
|
|
11
|
+
import type { LionRapidApiClient } from '../api-client.js';
|
|
12
|
+
/**
|
|
13
|
+
* Harvest the target locales a repo *already has* by matching the detected
|
|
14
|
+
* `target` patterns against real files — so `init` can scaffold what's on
|
|
15
|
+
* disk instead of a hardcoded guess when the server has nothing to say.
|
|
16
|
+
* Multi-locale catalogs (no `{locale}` in the path) can't be detected this
|
|
17
|
+
* way and are skipped. Returns sorted, deduped locales minus `sourceLocale`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function detectLocalesOnDisk(cwd: string, sourceLocale: string): string[];
|
|
20
|
+
/**
|
|
21
|
+
* What the project-config lookup produced. `no-targets` is deliberately
|
|
22
|
+
* distinct from `unavailable`: a brand-new project answers perfectly well and
|
|
23
|
+
* simply has no target languages yet — reporting that as "couldn't read your
|
|
24
|
+
* locales" hides the one thing the user has to go do.
|
|
25
|
+
*/
|
|
26
|
+
export type LocaleDefaults = {
|
|
27
|
+
status: 'ok';
|
|
28
|
+
sourceLocale: string;
|
|
29
|
+
targetLocales: string[];
|
|
30
|
+
} | {
|
|
31
|
+
status: 'no-targets';
|
|
32
|
+
sourceLocale: string;
|
|
33
|
+
} | {
|
|
34
|
+
status: 'unavailable';
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Fetch the project's configured locales for scaffold prefill. Never throws —
|
|
38
|
+
* offline, an older server, or a bad key all degrade to `unavailable`, so
|
|
39
|
+
* `init` keeps working fully offline.
|
|
40
|
+
*/
|
|
41
|
+
export declare function fetchLocaleDefaults(client: Pick<LionRapidApiClient, 'getProjectConfig'>): Promise<LocaleDefaults>;
|
|
11
42
|
interface DetectedFile {
|
|
12
43
|
format: FileFormat;
|
|
13
44
|
source: string;
|
|
14
45
|
target: string;
|
|
46
|
+
namespace?: string;
|
|
15
47
|
}
|
|
16
48
|
export interface DetectResult {
|
|
17
49
|
stacks: string[];
|
|
@@ -26,10 +58,22 @@ export interface InitAnswers {
|
|
|
26
58
|
targetLocales?: string[];
|
|
27
59
|
force?: boolean;
|
|
28
60
|
}
|
|
61
|
+
/** One field `init` changed, or wanted to change but didn't. */
|
|
62
|
+
export interface InitChange {
|
|
63
|
+
path: string;
|
|
64
|
+
from?: string;
|
|
65
|
+
to: string;
|
|
66
|
+
}
|
|
29
67
|
export interface InitResult {
|
|
30
68
|
configPath: string;
|
|
31
69
|
yaml: string;
|
|
32
70
|
detected: DetectResult;
|
|
71
|
+
/** True when there was no config before this run. */
|
|
72
|
+
created: boolean;
|
|
73
|
+
/** Changes written (new fields/entries — nothing was overwritten). */
|
|
74
|
+
applied: InitChange[];
|
|
75
|
+
/** Differences left alone: your value stands until you edit or use --force. */
|
|
76
|
+
conflicts: InitChange[];
|
|
33
77
|
}
|
|
34
78
|
export declare function runInit(cwd: string, answers?: InitAnswers): InitResult;
|
|
35
79
|
export {};
|
package/dist/commands/init.js
CHANGED
|
Binary file
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `login` / `logout` — manage `~/.lionrapid/credentials`, the JSON map
|
|
3
|
+
* (`{ "default": "sk_…", "<server-url>": "sk_…" }`) that `resolveApiKey`
|
|
4
|
+
* reads. The key is verified against the server BEFORE persisting (a
|
|
5
|
+
* config:read probe, so publishable pk_ keys work too) — a failed
|
|
6
|
+
* verification writes nothing. Directory is 0700, file 0600.
|
|
7
|
+
*
|
|
8
|
+
* `--server` writes a server-scoped entry (the per-host opt-in that
|
|
9
|
+
* `resolveApiKey` demands for non-LionRapid hosts); bare `login` writes
|
|
10
|
+
* `default`. The key itself is never echoed or included in any envelope.
|
|
11
|
+
*/
|
|
12
|
+
import { type FetchImpl } from '../api-client.js';
|
|
13
|
+
export declare const DEFAULT_SERVER = "https://api.lionrapid.com";
|
|
14
|
+
export interface LoginOptions {
|
|
15
|
+
/** Save the key scoped to this server URL (omitted: save as `default`). */
|
|
16
|
+
server?: string;
|
|
17
|
+
/** The API key (omitted: hidden interactive prompt). */
|
|
18
|
+
key?: string;
|
|
19
|
+
/** Credentials file path (defaults to ~/.lionrapid/credentials). */
|
|
20
|
+
credentialsPath?: string;
|
|
21
|
+
/** Directory to look for a repo lionrapid.yml whose server becomes the ping target. */
|
|
22
|
+
cwd?: string;
|
|
23
|
+
/** Injectable fetch for the verification ping. */
|
|
24
|
+
fetchImpl?: FetchImpl;
|
|
25
|
+
/** Injectable prompt (defaults to a muted readline prompt on stderr). */
|
|
26
|
+
promptKey?: () => Promise<string>;
|
|
27
|
+
}
|
|
28
|
+
export interface LoginResult {
|
|
29
|
+
server: string;
|
|
30
|
+
scope: 'default' | 'server';
|
|
31
|
+
credentialsPath: string;
|
|
32
|
+
}
|
|
33
|
+
export interface LogoutOptions {
|
|
34
|
+
server?: string;
|
|
35
|
+
credentialsPath?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface LogoutResult {
|
|
38
|
+
scope: 'default' | 'server';
|
|
39
|
+
/** True when an entry was actually deleted. */
|
|
40
|
+
removed: boolean;
|
|
41
|
+
credentialsPath: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function runLogin(opts?: LoginOptions): Promise<LoginResult>;
|
|
44
|
+
export declare function runLogout(opts?: LogoutOptions): LogoutResult;
|
|
45
|
+
/** Hidden interactive prompt (no echo) via node:readline — no new dependency. */
|
|
46
|
+
export declare function promptHidden(question?: string): Promise<string>;
|
|
47
|
+
//# sourceMappingURL=login.d.ts.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `login` / `logout` — manage `~/.lionrapid/credentials`, the JSON map
|
|
3
|
+
* (`{ "default": "sk_…", "<server-url>": "sk_…" }`) that `resolveApiKey`
|
|
4
|
+
* reads. The key is verified against the server BEFORE persisting (a
|
|
5
|
+
* config:read probe, so publishable pk_ keys work too) — a failed
|
|
6
|
+
* verification writes nothing. Directory is 0700, file 0600.
|
|
7
|
+
*
|
|
8
|
+
* `--server` writes a server-scoped entry (the per-host opt-in that
|
|
9
|
+
* `resolveApiKey` demands for non-LionRapid hosts); bare `login` writes
|
|
10
|
+
* `default`. The key itself is never echoed or included in any envelope.
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { ApiKeyError, assertKeyCharset, defaultCredentialsPath, findConfigPath, loadConfig, } from '../config.js';
|
|
16
|
+
import { LionRapidApiClient } from '../api-client.js';
|
|
17
|
+
export const DEFAULT_SERVER = 'https://api.lionrapid.com';
|
|
18
|
+
export async function runLogin(opts = {}) {
|
|
19
|
+
const scope = opts.server ? 'server' : 'default';
|
|
20
|
+
const server = normalizeServer(opts.server ?? repoServer(opts.cwd) ?? DEFAULT_SERVER);
|
|
21
|
+
const key = (opts.key ?? (await (opts.promptKey ?? promptHidden)())).trim();
|
|
22
|
+
if (!key)
|
|
23
|
+
throw new ApiKeyError('No API key provided.');
|
|
24
|
+
assertKeyCharset(key);
|
|
25
|
+
// Verify before persisting — a bad key must never land in the file. The
|
|
26
|
+
// probe is project-config (`config:read`), which every key class carries —
|
|
27
|
+
// publishable pk_ keys included; the old editor/workflow ping needed
|
|
28
|
+
// `targets:write` and wrongly rejected pk_ logins.
|
|
29
|
+
const client = new LionRapidApiClient({
|
|
30
|
+
baseUrl: server,
|
|
31
|
+
apiKey: key,
|
|
32
|
+
fetchImpl: opts.fetchImpl,
|
|
33
|
+
});
|
|
34
|
+
await client.getProjectConfig();
|
|
35
|
+
const path = opts.credentialsPath ?? defaultCredentialsPath();
|
|
36
|
+
const creds = readCreds(path);
|
|
37
|
+
creds[scope === 'server' ? server : 'default'] = key;
|
|
38
|
+
writeCreds(path, creds);
|
|
39
|
+
return { server, scope, credentialsPath: path };
|
|
40
|
+
}
|
|
41
|
+
export function runLogout(opts = {}) {
|
|
42
|
+
const scope = opts.server ? 'server' : 'default';
|
|
43
|
+
const entry = opts.server ? normalizeServer(opts.server) : 'default';
|
|
44
|
+
const path = opts.credentialsPath ?? defaultCredentialsPath();
|
|
45
|
+
if (!existsSync(path))
|
|
46
|
+
return { scope, removed: false, credentialsPath: path };
|
|
47
|
+
const creds = readCreds(path);
|
|
48
|
+
const removed = Object.prototype.hasOwnProperty.call(creds, entry);
|
|
49
|
+
if (removed) {
|
|
50
|
+
delete creds[entry];
|
|
51
|
+
if (Object.keys(creds).length === 0) {
|
|
52
|
+
rmSync(path);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
writeCreds(path, creds);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { scope, removed, credentialsPath: path };
|
|
59
|
+
}
|
|
60
|
+
/** Match the `config.server` normalization so scoped lookups hit exactly. */
|
|
61
|
+
function normalizeServer(server) {
|
|
62
|
+
return server.replace(/\/$/, '');
|
|
63
|
+
}
|
|
64
|
+
/** The repo's configured server, when a valid lionrapid.yml is present. */
|
|
65
|
+
function repoServer(cwd) {
|
|
66
|
+
const path = findConfigPath(cwd);
|
|
67
|
+
if (!path)
|
|
68
|
+
return undefined;
|
|
69
|
+
try {
|
|
70
|
+
return loadConfig(path).server;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function readCreds(path) {
|
|
77
|
+
if (!existsSync(path))
|
|
78
|
+
return {};
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
throw new ApiKeyError(`Could not parse credentials file ${path}: ${err instanceof Error ? err.message : String(err)}. Fix or delete it, then retry.`);
|
|
85
|
+
}
|
|
86
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
87
|
+
throw new ApiKeyError(`Credentials file ${path} is not a JSON object. Fix or delete it, then retry.`);
|
|
88
|
+
}
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
function writeCreds(path, creds) {
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
93
|
+
writeFileSync(path, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
|
|
94
|
+
// writeFileSync's mode only applies on create — enforce it on merges too.
|
|
95
|
+
chmodSync(path, 0o600);
|
|
96
|
+
}
|
|
97
|
+
/** Hidden interactive prompt (no echo) via node:readline — no new dependency. */
|
|
98
|
+
export function promptHidden(question = 'API key (input hidden): ') {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
101
|
+
process.stderr.write(question);
|
|
102
|
+
rl._writeToOutput = () => { };
|
|
103
|
+
rl.question('', (answer) => {
|
|
104
|
+
rl.close();
|
|
105
|
+
process.stderr.write('\n');
|
|
106
|
+
resolve(answer);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=login.js.map
|
package/dist/commands/pull.d.ts
CHANGED
|
@@ -9,16 +9,25 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { LionRapidConfig } from '../config.js';
|
|
11
11
|
import type { LionRapidApiClient } from '../api-client.js';
|
|
12
|
+
import { type TranslationEntry } from '../adapters/index.js';
|
|
12
13
|
export interface PullOptions {
|
|
13
14
|
cwd: string;
|
|
14
15
|
/** Fetch + count what would be written, but write no files. */
|
|
15
16
|
dryRun?: boolean;
|
|
17
|
+
/** Overwrite target files wholesale instead of merging (drops local-only keys). */
|
|
18
|
+
force?: boolean;
|
|
16
19
|
/** Progress callback, invoked as each namespace fetch completes. */
|
|
17
20
|
onProgress?: (done: number, total: number) => void;
|
|
18
21
|
}
|
|
19
22
|
export interface PullFileResult {
|
|
20
23
|
target: string;
|
|
21
24
|
count: number;
|
|
25
|
+
/** Merge diff vs the existing target (absent on fresh writes and --force). */
|
|
26
|
+
added?: number;
|
|
27
|
+
updated?: number;
|
|
28
|
+
keptLocal?: number;
|
|
29
|
+
/** Set when this file failed (fetch or write) — the file was not written. */
|
|
30
|
+
error?: string;
|
|
22
31
|
}
|
|
23
32
|
export interface PullLocaleResult {
|
|
24
33
|
locale: string;
|
|
@@ -31,5 +40,18 @@ export interface PullResult {
|
|
|
31
40
|
/** True when this was a dry run — no files were written. */
|
|
32
41
|
dryRun?: boolean;
|
|
33
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Overlay fetched entries onto the existing local file: server wins per key,
|
|
45
|
+
* local-only entries survive, existing key order is preserved and new keys
|
|
46
|
+
* append. Local `comment`s survive even on overlaid keys — the server bundle
|
|
47
|
+
* has no comment channel, so replacing the entry would silently drop them.
|
|
48
|
+
* Exported for direct unit testing.
|
|
49
|
+
*/
|
|
50
|
+
export declare function mergeEntries(local: TranslationEntry[], fetched: TranslationEntry[]): {
|
|
51
|
+
entries: TranslationEntry[];
|
|
52
|
+
added: number;
|
|
53
|
+
updated: number;
|
|
54
|
+
keptLocal: number;
|
|
55
|
+
};
|
|
34
56
|
export declare function runPull(config: LionRapidConfig, client: LionRapidApiClient, opts: PullOptions): Promise<PullResult>;
|
|
35
57
|
//# sourceMappingURL=pull.d.ts.map
|
package/dist/commands/pull.js
CHANGED
|
@@ -10,7 +10,40 @@
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { dirname, resolve } from 'node:path';
|
|
12
12
|
import { getAdapter } from '../adapters/index.js';
|
|
13
|
-
import { mapLimit, resolveConcurrency } from './context.js';
|
|
13
|
+
import { assertInside, mapLimit, resolveConcurrency } from './context.js';
|
|
14
|
+
/**
|
|
15
|
+
* Overlay fetched entries onto the existing local file: server wins per key,
|
|
16
|
+
* local-only entries survive, existing key order is preserved and new keys
|
|
17
|
+
* append. Local `comment`s survive even on overlaid keys — the server bundle
|
|
18
|
+
* has no comment channel, so replacing the entry would silently drop them.
|
|
19
|
+
* Exported for direct unit testing.
|
|
20
|
+
*/
|
|
21
|
+
export function mergeEntries(local, fetched) {
|
|
22
|
+
const newByKey = new Map(fetched.map((e) => [e.key, e]));
|
|
23
|
+
const entries = [];
|
|
24
|
+
let updated = 0;
|
|
25
|
+
let keptLocal = 0;
|
|
26
|
+
for (const l of local) {
|
|
27
|
+
const f = newByKey.get(l.key);
|
|
28
|
+
if (!f) {
|
|
29
|
+
keptLocal += 1;
|
|
30
|
+
entries.push(l);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (JSON.stringify(f.value) !== JSON.stringify(l.value))
|
|
34
|
+
updated += 1;
|
|
35
|
+
entries.push(l.comment !== undefined ? { ...f, comment: l.comment } : f);
|
|
36
|
+
newByKey.delete(l.key); // consumed — what remains is genuinely new
|
|
37
|
+
}
|
|
38
|
+
let added = 0;
|
|
39
|
+
for (const f of fetched) {
|
|
40
|
+
if (newByKey.has(f.key)) {
|
|
41
|
+
entries.push(f);
|
|
42
|
+
added += 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { entries, added, updated, keptLocal };
|
|
46
|
+
}
|
|
14
47
|
/** Union of source keys per namespace, for "pending" reporting. */
|
|
15
48
|
function sourceKeysByNamespace(config, cwd) {
|
|
16
49
|
const byNs = new Map();
|
|
@@ -31,16 +64,29 @@ export async function runPull(config, client, opts) {
|
|
|
31
64
|
const sourceKeys = sourceKeysByNamespace(config, opts.cwd);
|
|
32
65
|
const perLocaleFiles = config.files.filter((f) => !getAdapter(f.format).multiLocale);
|
|
33
66
|
const multiFiles = config.files.filter((f) => getAdapter(f.format).multiLocale);
|
|
34
|
-
// Run-wide namespace cache, keyed `${locale}:${namespace}`.
|
|
67
|
+
// Run-wide namespace cache, keyed `${locale}:${namespace}`. A failed fetch
|
|
68
|
+
// is cached as an error instead of aborting the run — one bad namespace
|
|
69
|
+
// must not zero out a 56-file pull (partial progress, exit 5).
|
|
35
70
|
const nsCache = new Map();
|
|
71
|
+
const nsErrors = new Map();
|
|
72
|
+
let firstError;
|
|
36
73
|
const fetchNs = async (locale, namespace) => {
|
|
37
74
|
const cacheKey = `${locale}:${namespace}`;
|
|
38
75
|
const hit = nsCache.get(cacheKey);
|
|
39
76
|
if (hit)
|
|
40
77
|
return hit;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
78
|
+
if (nsErrors.has(cacheKey))
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const bundle = await client.getNamespace(locale, namespace);
|
|
82
|
+
nsCache.set(cacheKey, bundle.translations);
|
|
83
|
+
return bundle.translations;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
firstError ??= err;
|
|
87
|
+
nsErrors.set(cacheKey, err instanceof Error ? err : new Error(String(err)));
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
44
90
|
};
|
|
45
91
|
// Pre-fetch every needed (locale, namespace) bundle concurrently so the slow
|
|
46
92
|
// network fan-out runs in parallel; the write loops below then read the warm
|
|
@@ -75,30 +121,85 @@ export async function runPull(config, client, opts) {
|
|
|
75
121
|
const adapter = getAdapter(file.format);
|
|
76
122
|
const namespace = file.namespace ?? config.namespace;
|
|
77
123
|
usedNamespaces.add(namespace);
|
|
78
|
-
const entries = toEntries(await fetchNs(locale, namespace));
|
|
79
124
|
const relPath = file.target.replace('{locale}', locale);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
const translations = await fetchNs(locale, namespace);
|
|
126
|
+
if (translations === null) {
|
|
127
|
+
fileResults.push({
|
|
128
|
+
target: relPath,
|
|
129
|
+
count: 0,
|
|
130
|
+
error: nsErrors.get(`${locale}:${namespace}`).message,
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const fetched = toEntries(translations);
|
|
135
|
+
try {
|
|
136
|
+
const absPath = assertInside(opts.cwd, relPath);
|
|
137
|
+
// Default is a non-destructive merge with the existing target: server
|
|
138
|
+
// wins per key, local-only keys survive. `--force` restores the
|
|
139
|
+
// wholesale overwrite.
|
|
140
|
+
let toWrite = fetched;
|
|
141
|
+
let diff;
|
|
142
|
+
if (!opts.force && fetched.length > 0 && existsSync(absPath)) {
|
|
143
|
+
const merged = mergeEntries(adapter.parse(readFileSync(absPath, 'utf8')), fetched);
|
|
144
|
+
toWrite = merged.entries;
|
|
145
|
+
diff = { added: merged.added, updated: merged.updated, keptLocal: merged.keptLocal };
|
|
146
|
+
}
|
|
147
|
+
// Don't clobber an existing target with an empty result (locked/pending).
|
|
148
|
+
// Dry run computes counts but writes nothing.
|
|
149
|
+
if (fetched.length > 0 && !opts.dryRun) {
|
|
150
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
151
|
+
writeFileSync(absPath, adapter.serialize(toWrite, {
|
|
152
|
+
locale,
|
|
153
|
+
sourceLocale: config.sourceLocale,
|
|
154
|
+
flat: file.flat,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
fileResults.push({ target: relPath, count: fetched.length, ...(diff ?? {}) });
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
firstError ??= err;
|
|
161
|
+
fileResults.push({
|
|
162
|
+
target: relPath,
|
|
163
|
+
count: 0,
|
|
164
|
+
error: err instanceof Error ? err.message : String(err),
|
|
165
|
+
});
|
|
86
166
|
}
|
|
87
|
-
fileResults.push({ target: relPath, count: entries.length });
|
|
88
167
|
}
|
|
89
168
|
for (const file of multiFiles) {
|
|
90
169
|
const adapter = getAdapter(file.format);
|
|
91
170
|
const namespace = file.namespace ?? config.namespace;
|
|
92
171
|
usedNamespaces.add(namespace);
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
172
|
+
const translations = await fetchNs(locale, namespace);
|
|
173
|
+
if (translations === null) {
|
|
174
|
+
fileResults.push({
|
|
175
|
+
target: file.target,
|
|
176
|
+
count: 0,
|
|
177
|
+
error: nsErrors.get(`${locale}:${namespace}`).message,
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const entries = toEntries(translations);
|
|
182
|
+
try {
|
|
183
|
+
if (entries.length > 0) {
|
|
184
|
+
const merged = adapter.mergeLocale(catalogContent.get(file) ?? null, entries, locale);
|
|
185
|
+
catalogContent.set(file, merged);
|
|
186
|
+
}
|
|
187
|
+
fileResults.push({ target: file.target, count: entries.length });
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
firstError ??= err;
|
|
191
|
+
fileResults.push({
|
|
192
|
+
target: file.target,
|
|
193
|
+
count: 0,
|
|
194
|
+
error: err instanceof Error ? err.message : String(err),
|
|
195
|
+
});
|
|
97
196
|
}
|
|
98
|
-
fileResults.push({ target: file.target, count: entries.length });
|
|
99
197
|
}
|
|
100
198
|
let pending = 0;
|
|
101
199
|
for (const namespace of usedNamespaces) {
|
|
200
|
+
// A failed namespace's state is unknown — don't report its keys pending.
|
|
201
|
+
if (nsErrors.has(`${locale}:${namespace}`))
|
|
202
|
+
continue;
|
|
102
203
|
const have = new Set(Object.keys(nsCache.get(`${locale}:${namespace}`) ?? {}));
|
|
103
204
|
for (const key of sourceKeys.get(namespace) ?? new Set()) {
|
|
104
205
|
if (!have.has(key))
|
|
@@ -112,12 +213,30 @@ export async function runPull(config, client, opts) {
|
|
|
112
213
|
for (const file of multiFiles) {
|
|
113
214
|
const content = catalogContent.get(file);
|
|
114
215
|
if (content != null) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
216
|
+
try {
|
|
217
|
+
const absPath = assertInside(opts.cwd, file.target);
|
|
218
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
219
|
+
writeFileSync(absPath, content);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
firstError ??= err;
|
|
223
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
224
|
+
for (const loc of locales) {
|
|
225
|
+
for (const f of loc.files) {
|
|
226
|
+
if (f.target === file.target && !f.error)
|
|
227
|
+
f.error = message;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
118
231
|
}
|
|
119
232
|
}
|
|
120
233
|
}
|
|
234
|
+
// Partial progress is a result; total failure keeps today's error contract —
|
|
235
|
+
// rethrow the first error so the exit-code mapping is unchanged.
|
|
236
|
+
const all = locales.flatMap((l) => l.files);
|
|
237
|
+
if (all.length > 0 && all.every((f) => f.error) && firstError !== undefined) {
|
|
238
|
+
throw firstError;
|
|
239
|
+
}
|
|
121
240
|
return { locales, ...(opts.dryRun ? { dryRun: true } : {}) };
|
|
122
241
|
}
|
|
123
242
|
//# sourceMappingURL=pull.js.map
|
package/dist/commands/push.d.ts
CHANGED
|
@@ -5,11 +5,34 @@
|
|
|
5
5
|
* rows via `sync/batch`; the server then queues machine translation for the
|
|
6
6
|
* configured target locales.
|
|
7
7
|
*
|
|
8
|
+
* `sync/batch` answers 202 Accepted — the rows are QUEUED, not yet stored. So
|
|
9
|
+
* every batch also yields job ids ({@link PushedJob}), which is the only thing
|
|
10
|
+
* that lets a caller tell "the backend stored this" from "the backend dropped
|
|
11
|
+
* it". Without them a push that dead-letters looks exactly like one that worked.
|
|
12
|
+
*
|
|
8
13
|
* `--target <locale>` mode uploads an already-translated target file as drafts
|
|
9
14
|
* via `targets/batch`, optionally publishing them with `--publish`.
|
|
10
15
|
*/
|
|
11
16
|
import { type LionRapidConfig } from '../config.js';
|
|
12
17
|
import type { LionRapidApiClient } from '../api-client.js';
|
|
18
|
+
/**
|
|
19
|
+
* A sync job the server queued for one batch of this push.
|
|
20
|
+
*
|
|
21
|
+
* `sync/batch` answers 202 — accepted, not stored. This is the receipt that
|
|
22
|
+
* makes the difference observable: it says which file's keys are riding on
|
|
23
|
+
* which job, so `lionrapid status <id>` can later answer "stored" or "dropped".
|
|
24
|
+
* Target mode has no equivalent because `targets/batch` writes synchronously.
|
|
25
|
+
*/
|
|
26
|
+
export interface PushedJob {
|
|
27
|
+
id: string;
|
|
28
|
+
/** The file whose keys were in this batch. */
|
|
29
|
+
file: string;
|
|
30
|
+
namespace: string;
|
|
31
|
+
/** Keys in this batch (a large file is chunked into several jobs). */
|
|
32
|
+
keys: number;
|
|
33
|
+
/** Status the server reported at enqueue time (`pending`). */
|
|
34
|
+
status: string;
|
|
35
|
+
}
|
|
13
36
|
export interface PushOptions {
|
|
14
37
|
cwd: string;
|
|
15
38
|
/** Push an already-translated target locale instead of the source. */
|
|
@@ -18,16 +41,29 @@ export interface PushOptions {
|
|
|
18
41
|
publish?: boolean;
|
|
19
42
|
/** Parse + count what would be pushed, but make no network calls. */
|
|
20
43
|
dryRun?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Called as each batch is accepted. The caller persists these, so ids queued
|
|
46
|
+
* before a later total failure are not lost when `runPush` rethrows.
|
|
47
|
+
*/
|
|
48
|
+
onJob?: (job: PushedJob) => void;
|
|
21
49
|
}
|
|
22
50
|
export interface PushFileResult {
|
|
23
51
|
file: string;
|
|
24
52
|
count: number;
|
|
25
53
|
skipped?: boolean;
|
|
54
|
+
/** Set when this file failed (parse or upload) — it was not (fully) pushed. */
|
|
55
|
+
error?: string;
|
|
26
56
|
}
|
|
27
57
|
export interface PushResult {
|
|
28
58
|
mode: 'source' | 'target';
|
|
59
|
+
/**
|
|
60
|
+
* Keys the CLI SENT. Not the number stored — the server queues them, so
|
|
61
|
+
* `jobs` is what says whether they landed.
|
|
62
|
+
*/
|
|
29
63
|
pushed: number;
|
|
30
64
|
files: PushFileResult[];
|
|
65
|
+
/** Jobs the server queued for this push (source mode only; empty on a dry run). */
|
|
66
|
+
jobs: PushedJob[];
|
|
31
67
|
/** True when `--publish` was requested but no editor token was available. */
|
|
32
68
|
publishSkipped?: boolean;
|
|
33
69
|
/** True when this was a dry run — nothing was sent to the server. */
|