@lionrapid/cli 0.2.0 → 0.2.2
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 +191 -0
- package/README.md +38 -3
- package/dist/adapters/android-xml.js +29 -6
- package/dist/adapters/json.d.ts +2 -2
- package/dist/adapters/json.js +4 -3
- package/dist/adapters/nested.d.ts +7 -1
- package/dist/adapters/nested.js +23 -2
- package/dist/adapters/types.d.ts +2 -0
- package/dist/adapters/yaml.js +3 -3
- package/dist/api-client.d.ts +32 -0
- package/dist/api-client.js +106 -6
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +240 -21
- 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 +2 -0
- package/dist/commands/push.js +39 -16
- package/dist/commands/snapshot.js +3 -2
- 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 +46 -1
- package/dist/config.js +209 -14
- package/dist/types-gen/extract.js +7 -1
- package/dist/types-gen/generate.js +9 -1
- package/package.json +2 -1
- package/dist/adapters/android-xml.d.ts.map +0 -1
- package/dist/adapters/android-xml.js.map +0 -1
- package/dist/adapters/arb.d.ts.map +0 -1
- package/dist/adapters/arb.js.map +0 -1
- package/dist/adapters/content.d.ts.map +0 -1
- package/dist/adapters/content.js.map +0 -1
- package/dist/adapters/index.d.ts.map +0 -1
- package/dist/adapters/index.js.map +0 -1
- package/dist/adapters/ios-strings.d.ts.map +0 -1
- package/dist/adapters/ios-strings.js.map +0 -1
- package/dist/adapters/json.d.ts.map +0 -1
- package/dist/adapters/json.js.map +0 -1
- package/dist/adapters/nested.d.ts.map +0 -1
- package/dist/adapters/nested.js.map +0 -1
- package/dist/adapters/po.d.ts.map +0 -1
- package/dist/adapters/po.js.map +0 -1
- package/dist/adapters/types.d.ts.map +0 -1
- package/dist/adapters/types.js.map +0 -1
- package/dist/adapters/xcstrings.d.ts.map +0 -1
- package/dist/adapters/xcstrings.js.map +0 -1
- package/dist/adapters/xliff.d.ts.map +0 -1
- package/dist/adapters/xliff.js.map +0 -1
- package/dist/adapters/yaml.d.ts.map +0 -1
- package/dist/adapters/yaml.js.map +0 -1
- package/dist/api-client.d.ts.map +0 -1
- package/dist/api-client.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/commands/context.d.ts.map +0 -1
- package/dist/commands/context.js.map +0 -1
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/init.d.ts.map +0 -1
- package/dist/commands/init.js.map +0 -1
- package/dist/commands/pull.d.ts.map +0 -1
- package/dist/commands/pull.js.map +0 -1
- package/dist/commands/push.d.ts.map +0 -1
- package/dist/commands/push.js.map +0 -1
- package/dist/commands/scan.d.ts.map +0 -1
- package/dist/commands/scan.js.map +0 -1
- package/dist/commands/snapshot.d.ts.map +0 -1
- package/dist/commands/snapshot.js.map +0 -1
- package/dist/commands/types.d.ts.map +0 -1
- package/dist/commands/types.js.map +0 -1
- package/dist/commands/validate.d.ts.map +0 -1
- package/dist/commands/validate.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/exit-codes.d.ts.map +0 -1
- package/dist/exit-codes.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/output.d.ts.map +0 -1
- package/dist/output.js.map +0 -1
- package/dist/scan/canonical.d.ts.map +0 -1
- package/dist/scan/canonical.js.map +0 -1
- package/dist/scan/discover.d.ts.map +0 -1
- package/dist/scan/discover.js.map +0 -1
- package/dist/scan/scanner.d.ts.map +0 -1
- package/dist/scan/scanner.js.map +0 -1
- package/dist/types-gen/extract.d.ts.map +0 -1
- package/dist/types-gen/extract.js.map +0 -1
- package/dist/types-gen/generate.d.ts.map +0 -1
- package/dist/types-gen/generate.js.map +0 -1
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
|
@@ -23,6 +23,8 @@ export interface PushFileResult {
|
|
|
23
23
|
file: string;
|
|
24
24
|
count: number;
|
|
25
25
|
skipped?: boolean;
|
|
26
|
+
/** Set when this file failed (parse or upload) — it was not (fully) pushed. */
|
|
27
|
+
error?: string;
|
|
26
28
|
}
|
|
27
29
|
export interface PushResult {
|
|
28
30
|
mode: 'source' | 'target';
|
package/dist/commands/push.js
CHANGED
|
@@ -13,6 +13,8 @@ import { resolve } from 'node:path';
|
|
|
13
13
|
import { ConfigError } from '../config.js';
|
|
14
14
|
import { getAdapter } from '../adapters/index.js';
|
|
15
15
|
import { chunk } from './context.js';
|
|
16
|
+
// The server's enforced ceiling is 1000 items per sync/targets batch (Pydantic
|
|
17
|
+
// `max_length` on the request model) — 500 stays comfortably under it.
|
|
16
18
|
const BATCH = 500;
|
|
17
19
|
export async function runPush(config, client, opts) {
|
|
18
20
|
const mode = opts.target ? 'target' : 'source';
|
|
@@ -32,6 +34,7 @@ export async function runPush(config, client, opts) {
|
|
|
32
34
|
const canPublish = mode === 'target' && opts.publish === true && client.canPublish;
|
|
33
35
|
const publishSkipped = mode === 'target' && opts.publish === true && !client.canPublish;
|
|
34
36
|
let pushed = 0;
|
|
37
|
+
let firstError;
|
|
35
38
|
for (const file of config.files) {
|
|
36
39
|
const adapter = getAdapter(file.format);
|
|
37
40
|
const namespace = file.namespace ?? config.namespace;
|
|
@@ -41,27 +44,47 @@ export async function runPush(config, client, opts) {
|
|
|
41
44
|
files.push({ file: relPath, count: 0, skipped: true });
|
|
42
45
|
continue;
|
|
43
46
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
// One bad file/namespace must not zero out the rest of the push (a rate
|
|
48
|
+
// limit on a many-namespace site, one corrupt file). Record the failure
|
|
49
|
+
// and move on — sync/batch upserts by composite key, so re-running push
|
|
50
|
+
// retries just the failures (partial progress, exit 5).
|
|
51
|
+
try {
|
|
52
|
+
const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
|
|
53
|
+
const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
|
|
54
|
+
const items = entries.map((e) => ({ key: e.key, value: e.value }));
|
|
55
|
+
// Dry run: parse + count only, never touch the network.
|
|
56
|
+
if (!opts.dryRun) {
|
|
57
|
+
for (const batch of chunk(items, BATCH)) {
|
|
58
|
+
if (mode === 'source') {
|
|
59
|
+
await client.syncBatch(config.sourceLocale, namespace, batch);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
await client.targetsBatch(opts.target, namespace, batch);
|
|
63
|
+
if (canPublish) {
|
|
64
|
+
for (const it of batch) {
|
|
65
|
+
await client.markReady(opts.target, namespace, it.key);
|
|
66
|
+
}
|
|
58
67
|
}
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
}
|
|
71
|
+
pushed += items.length;
|
|
72
|
+
files.push({ file: relPath, count: items.length });
|
|
62
73
|
}
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
catch (err) {
|
|
75
|
+
firstError ??= err;
|
|
76
|
+
files.push({
|
|
77
|
+
file: relPath,
|
|
78
|
+
count: 0,
|
|
79
|
+
error: err instanceof Error ? err.message : String(err),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Partial progress is a result; total failure keeps today's error contract —
|
|
84
|
+
// rethrow the first error so the exit-code mapping is unchanged.
|
|
85
|
+
const attempted = files.filter((f) => !f.skipped);
|
|
86
|
+
if (attempted.length > 0 && attempted.every((f) => f.error) && firstError !== undefined) {
|
|
87
|
+
throw firstError;
|
|
65
88
|
}
|
|
66
89
|
return {
|
|
67
90
|
mode,
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* surface with no re-derivation.
|
|
14
14
|
*/
|
|
15
15
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
-
import { dirname
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
import { assertInside } from './context.js';
|
|
17
18
|
export const DEFAULT_SNAPSHOT_DIR = 'snapshots';
|
|
18
19
|
export async function runSnapshot(config, client, opts) {
|
|
19
20
|
const out = opts.out ?? DEFAULT_SNAPSHOT_DIR;
|
|
@@ -41,7 +42,7 @@ export async function runSnapshot(config, client, opts) {
|
|
|
41
42
|
translations: bundle.translations, // raw — styles preserved
|
|
42
43
|
};
|
|
43
44
|
const rel = `${out}/${locale}.${namespace}.json`;
|
|
44
|
-
const abs =
|
|
45
|
+
const abs = assertInside(opts.cwd, rel);
|
|
45
46
|
mkdirSync(dirname(abs), { recursive: true });
|
|
46
47
|
writeFileSync(abs, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
47
48
|
files.push({ path: rel, locale, namespace, keys });
|
package/dist/commands/types.js
CHANGED
|
@@ -14,6 +14,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
14
14
|
import { getAdapter } from '../adapters/index.js';
|
|
15
15
|
import { generateDts } from '../types-gen/generate.js';
|
|
16
16
|
import { extractParams, IcuError } from '../types-gen/extract.js';
|
|
17
|
+
import { assertInside } from './context.js';
|
|
17
18
|
const DEFAULT_OUTPUT = 'types/translations.d.ts';
|
|
18
19
|
/** Map every source key to its ICU message text (first definition wins). */
|
|
19
20
|
export function collectSourceKeyTexts(config, cwd) {
|
|
@@ -50,7 +51,7 @@ export async function collectServerKeyTexts(config, client) {
|
|
|
50
51
|
function writeDts(keyTexts, config, opts) {
|
|
51
52
|
const dts = generateDts(keyTexts, { strictIcu: opts.strictIcu });
|
|
52
53
|
const output = opts.output ?? config.typesOutput ?? DEFAULT_OUTPUT;
|
|
53
|
-
const abs =
|
|
54
|
+
const abs = assertInside(opts.cwd, output);
|
|
54
55
|
mkdirSync(dirname(abs), { recursive: true });
|
|
55
56
|
writeFileSync(abs, dts);
|
|
56
57
|
return { keys: keyTexts.size, output };
|