@lionrapid/cli 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/README.md +15 -1
- package/dist/api-client.d.ts +129 -1
- package/dist/api-client.js +139 -2
- package/dist/cli.js +469 -34
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +47 -5
- package/dist/commands/init.d.ts +73 -2
- package/dist/commands/init.js +0 -0
- package/dist/commands/login.d.ts +50 -4
- package/dist/commands/login.js +70 -16
- package/dist/commands/pull.d.ts +15 -1
- package/dist/commands/pull.js +92 -4
- package/dist/commands/push.d.ts +73 -0
- package/dist/commands/push.js +175 -3
- package/dist/commands/status.d.ts +91 -0
- package/dist/commands/status.js +232 -0
- package/dist/commands/whoami.d.ts +50 -0
- package/dist/commands/whoami.js +66 -0
- package/dist/config.d.ts +100 -5
- package/dist/config.js +239 -22
- package/dist/history.d.ts +44 -0
- package/dist/history.js +65 -0
- package/dist/job-history.d.ts +106 -0
- package/dist/job-history.js +163 -0
- package/dist/lockfile.d.ts +90 -0
- package/dist/lockfile.js +110 -0
- package/dist/time.d.ts +27 -0
- package/dist/time.js +53 -0
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -10,11 +10,33 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { resolve } from 'node:path';
|
|
13
|
-
import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
|
|
13
|
+
import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, keyCapability, listCredentialServers, ConfigError, } from '../config.js';
|
|
14
14
|
import { LionRapidApiClient } from '../api-client.js';
|
|
15
|
+
/**
|
|
16
|
+
* First line only. Right for a network error (whose `cause` chain is long and
|
|
17
|
+
* repetitive), wrong for a ConfigError — see {@link configDetail}.
|
|
18
|
+
*/
|
|
15
19
|
function msg(err) {
|
|
16
20
|
return err instanceof Error ? err.message.split('\n')[0] : String(err);
|
|
17
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* A config failure, with its issues.
|
|
24
|
+
*
|
|
25
|
+
* `msg()` kept the first line only, so `Invalid lionrapid config:` printed
|
|
26
|
+
* with every bullet the validator had produced discarded — a header promising
|
|
27
|
+
* a list, followed by nothing. Other commands print `err.message` whole and
|
|
28
|
+
* showed the bullets, which is what made the renderer look inconsistent: it is
|
|
29
|
+
* not per-field, it is per-command. ConfigError carries the issues as typed
|
|
30
|
+
* data, so use that rather than re-splitting a string.
|
|
31
|
+
*/
|
|
32
|
+
function configDetail(err) {
|
|
33
|
+
if (err instanceof ConfigError && err.issues.length > 0) {
|
|
34
|
+
return `${err.message.split('\n')[0]} ${err.issues
|
|
35
|
+
.map((i) => `${i.path}: ${i.message}`)
|
|
36
|
+
.join('; ')}`;
|
|
37
|
+
}
|
|
38
|
+
return msg(err);
|
|
39
|
+
}
|
|
18
40
|
export async function runDoctor(opts) {
|
|
19
41
|
const checks = [];
|
|
20
42
|
// 1. Config present + valid.
|
|
@@ -39,16 +61,36 @@ export async function runDoctor(opts) {
|
|
|
39
61
|
}
|
|
40
62
|
}
|
|
41
63
|
catch (err) {
|
|
42
|
-
checks.push({ name: 'config', ok: false, detail:
|
|
64
|
+
checks.push({ name: 'config', ok: false, detail: configDetail(err) });
|
|
43
65
|
}
|
|
44
66
|
// 2. API key resolvable (never print the key itself).
|
|
67
|
+
//
|
|
68
|
+
// Keys are stored PER SERVER URL, so without a valid config there is no
|
|
69
|
+
// server to look one up by — the lookup cannot run, it does not fail. Saying
|
|
70
|
+
// "No API key found" here asserted something false about a key that was
|
|
71
|
+
// present and correct, and sent the owner hunting through a credentials file
|
|
72
|
+
// that had nothing wrong with it. Report the truth: skipped, and what IS
|
|
73
|
+
// saved, so they can see for themselves.
|
|
45
74
|
let apiKey;
|
|
75
|
+
const savedScopes = listCredentialServers(opts.credentialsPath);
|
|
46
76
|
try {
|
|
47
|
-
apiKey = resolveApiKey({ server: config?.server });
|
|
48
|
-
|
|
77
|
+
apiKey = resolveApiKey({ server: config?.server, credentialsPath: opts.credentialsPath });
|
|
78
|
+
// Name what the key can DO, not just that it exists. A pk_ holder
|
|
79
|
+
// otherwise discovers the targets:write line mid-publish, as a bare 403.
|
|
80
|
+
checks.push({
|
|
81
|
+
name: 'apiKey',
|
|
82
|
+
ok: true,
|
|
83
|
+
detail: `resolved from env/credentials · ${keyCapability(apiKey).summary}`,
|
|
84
|
+
});
|
|
49
85
|
}
|
|
50
86
|
catch (err) {
|
|
51
|
-
checks.push({
|
|
87
|
+
checks.push({
|
|
88
|
+
name: 'apiKey',
|
|
89
|
+
ok: false,
|
|
90
|
+
detail: config
|
|
91
|
+
? msg(err)
|
|
92
|
+
: `skipped — needs a valid config (keys are saved per server URL)${savedScopes.length > 0 ? `; saved: ${savedScopes.join(', ')}` : ''}`,
|
|
93
|
+
});
|
|
52
94
|
}
|
|
53
95
|
// 3. Server reachable + token accepted (no retries — doctor wants fast feedback).
|
|
54
96
|
if (config && apiKey) {
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
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';
|
|
11
|
+
import type { LionRapidApiClient, FetchImpl } from '../api-client.js';
|
|
12
12
|
/**
|
|
13
13
|
* Harvest the target locales a repo *already has* by matching the detected
|
|
14
14
|
* `target` patterns against real files — so `init` can scaffold what's on
|
|
@@ -39,6 +39,65 @@ export type LocaleDefaults = {
|
|
|
39
39
|
* `init` keeps working fully offline.
|
|
40
40
|
*/
|
|
41
41
|
export declare function fetchLocaleDefaults(client: Pick<LionRapidApiClient, 'getProjectConfig'>): Promise<LocaleDefaults>;
|
|
42
|
+
export interface EnsureKeyOptions {
|
|
43
|
+
server: string;
|
|
44
|
+
/** A key supplied on the command line, if any. */
|
|
45
|
+
key?: string;
|
|
46
|
+
credentialsPath?: string;
|
|
47
|
+
/** Whether we may prompt. False in CI, pipes, agent runs. */
|
|
48
|
+
isTTY: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Which credentials entry a newly typed key lands in. Mirrors `login`'s own
|
|
51
|
+
* rule: `--server` given means a scoped entry, omitted means `default` —
|
|
52
|
+
* "my LionRapid account key". Two commands that both save credentials must
|
|
53
|
+
* not disagree about where they go.
|
|
54
|
+
*/
|
|
55
|
+
scope?: 'default' | 'server';
|
|
56
|
+
/** Injectable hidden prompt (defaults to the muted readline one). */
|
|
57
|
+
promptKey?: () => Promise<string>;
|
|
58
|
+
fetchImpl?: FetchImpl;
|
|
59
|
+
/**
|
|
60
|
+
* Environment to resolve `LIONRAPID_API_KEY` from (defaults to
|
|
61
|
+
* `process.env`).
|
|
62
|
+
*
|
|
63
|
+
* Explicit because the ambient environment is NOT a constant: the monorepo's
|
|
64
|
+
* full-suite runner (lerna → nx) loads the repo's tracked `.env`, which sets
|
|
65
|
+
* `LIONRAPID_API_KEY`. Tests that leave this to `process.env` therefore pass
|
|
66
|
+
* under `npm test -w packages/cli` and fail under `npm test` — a difference
|
|
67
|
+
* nobody sees until a pre-merge gate runs the whole suite.
|
|
68
|
+
*/
|
|
69
|
+
env?: NodeJS.ProcessEnv;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Get a usable API key, prompting and SAVING one if there is none.
|
|
73
|
+
*
|
|
74
|
+
* This is what collapses onboarding from three commands to one. `init` already
|
|
75
|
+
* prompted for a key — and then threw it away, using it only to prefill
|
|
76
|
+
* locales. You typed the same key into `login` and re-ran `init`. It is now
|
|
77
|
+
* verified and persisted through the same path `login` uses, so the key you
|
|
78
|
+
* just typed is the key the next command finds.
|
|
79
|
+
*
|
|
80
|
+
* A missing key is NOT refused the way a missing server is (C9): inventing a
|
|
81
|
+
* server writes a dead host into a committed file, whereas no key just means
|
|
82
|
+
* `init` cannot prefill locales — the config it writes is still correct.
|
|
83
|
+
*/
|
|
84
|
+
export declare function ensureKey(opts: EnsureKeyOptions): Promise<{
|
|
85
|
+
key?: string;
|
|
86
|
+
signedIn: boolean;
|
|
87
|
+
}>;
|
|
88
|
+
/**
|
|
89
|
+
* What the scaffold will actually push: strings, namespaces, files.
|
|
90
|
+
*
|
|
91
|
+
* `init` otherwise ends on a promise ("wrote lionrapid.yml") that the user can
|
|
92
|
+
* only check by running `push`. Parsing what it just declared turns the last
|
|
93
|
+
* line into a fact. A source that will not parse contributes nothing rather
|
|
94
|
+
* than failing the run — `doctor` and `push` are where that becomes an error.
|
|
95
|
+
*/
|
|
96
|
+
export declare function summarizeSources(cwd: string, files: DetectedFile[]): {
|
|
97
|
+
strings: number;
|
|
98
|
+
namespaces: number;
|
|
99
|
+
files: number;
|
|
100
|
+
};
|
|
42
101
|
interface DetectedFile {
|
|
43
102
|
format: FileFormat;
|
|
44
103
|
source: string;
|
|
@@ -52,7 +111,8 @@ export interface DetectResult {
|
|
|
52
111
|
/** Inspect the project for known i18n stacks and propose file mappings. */
|
|
53
112
|
export declare function detectProject(cwd: string): DetectResult;
|
|
54
113
|
export interface InitAnswers {
|
|
55
|
-
project
|
|
114
|
+
/** The project uuid the key resolved to, when the server disclosed one. */
|
|
115
|
+
projectId?: string;
|
|
56
116
|
server?: string;
|
|
57
117
|
sourceLocale?: string;
|
|
58
118
|
targetLocales?: string[];
|
|
@@ -74,6 +134,17 @@ export interface InitResult {
|
|
|
74
134
|
applied: InitChange[];
|
|
75
135
|
/** Differences left alone: your value stands until you edit or use --force. */
|
|
76
136
|
conflicts: InitChange[];
|
|
137
|
+
/**
|
|
138
|
+
* Declared `files[].source` paths with nothing behind them on disk.
|
|
139
|
+
*
|
|
140
|
+
* Reconcile matches entries by format+source, so a stale entry is never
|
|
141
|
+
* touched — a newly detected stanza is appended BESIDE it and reported as a
|
|
142
|
+
* cheerful `+ files[…]`. The user then meets `doctor: sources: 1 missing`
|
|
143
|
+
* with two entries and no idea which is wrong, because the one command that
|
|
144
|
+
* saw both said nothing. A glob counts as missing only when it matches no
|
|
145
|
+
* file: the pattern itself is not a path.
|
|
146
|
+
*/
|
|
147
|
+
missingSources: string[];
|
|
77
148
|
}
|
|
78
149
|
export declare function runInit(cwd: string, answers?: InitAnswers): InitResult;
|
|
79
150
|
export {};
|
package/dist/commands/init.js
CHANGED
|
Binary file
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -10,10 +10,25 @@
|
|
|
10
10
|
* `default`. The key itself is never echoed or included in any envelope.
|
|
11
11
|
*/
|
|
12
12
|
import { type FetchImpl } from '../api-client.js';
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The server a bare `login`/`init` targets. THE only copy of this host — a
|
|
15
|
+
* second literal in `init.ts` is how it silently drifted before.
|
|
16
|
+
*
|
|
17
|
+
* `api.lionrapid.com` (the previous value) has no DNS record, so every fresh
|
|
18
|
+
* `init` wrote an unreachable server; credentials are then saved per server
|
|
19
|
+
* URL, the next lookup misses, and the CLI blames a missing API key.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_SERVER = "https://app.lionrapid.com";
|
|
14
22
|
export interface LoginOptions {
|
|
15
23
|
/** Save the key scoped to this server URL (omitted: save as `default`). */
|
|
16
24
|
server?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Override which entry to write. `init` needs this: it has already resolved
|
|
27
|
+
* a server to VERIFY against (possibly from the repo's yml or the single
|
|
28
|
+
* saved credential), but should still write `default` unless the user
|
|
29
|
+
* actually passed `--server`. Defaults to login's own rule.
|
|
30
|
+
*/
|
|
31
|
+
scope?: 'default' | 'server';
|
|
17
32
|
/** The API key (omitted: hidden interactive prompt). */
|
|
18
33
|
key?: string;
|
|
19
34
|
/** Credentials file path (defaults to ~/.lionrapid/credentials). */
|
|
@@ -31,17 +46,48 @@ export interface LoginResult {
|
|
|
31
46
|
credentialsPath: string;
|
|
32
47
|
}
|
|
33
48
|
export interface LogoutOptions {
|
|
49
|
+
/** Remove exactly this scope. */
|
|
34
50
|
server?: string;
|
|
51
|
+
/** Remove every scope and delete the file. */
|
|
52
|
+
all?: boolean;
|
|
35
53
|
credentialsPath?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Ask which scope to remove when several are saved. Returning `undefined`
|
|
56
|
+
* cancels. Omitted = nothing can ask (CI, pipes), which REFUSES rather than
|
|
57
|
+
* guessing — see {@link runLogout}.
|
|
58
|
+
*/
|
|
59
|
+
choose?: (scopes: string[]) => Promise<string | undefined> | string | undefined;
|
|
36
60
|
}
|
|
37
61
|
export interface LogoutResult {
|
|
38
|
-
|
|
39
|
-
|
|
62
|
+
/** The entry removed: a server URL, `default`, or `all`. */
|
|
63
|
+
scope: string;
|
|
64
|
+
/** True when something was actually deleted. */
|
|
40
65
|
removed: boolean;
|
|
66
|
+
/** How many entries went (1, or the whole file under `--all`). */
|
|
67
|
+
removedScopes: number;
|
|
41
68
|
credentialsPath: string;
|
|
42
69
|
}
|
|
43
70
|
export declare function runLogin(opts?: LoginOptions): Promise<LoginResult>;
|
|
44
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Remove a saved credential.
|
|
73
|
+
*
|
|
74
|
+
* Bare `logout` used to remove ONLY `default` — surprising when you hold three
|
|
75
|
+
* scopes and expected to be signed out, and a silent no-op when you hold one
|
|
76
|
+
* scoped key and no default at all. The shape now (owner's decision,
|
|
77
|
+
* 2026-08-07):
|
|
78
|
+
*
|
|
79
|
+
* logout one scope saved → remove it
|
|
80
|
+
* several saved → ask which
|
|
81
|
+
* non-interactive → refuse, naming --server / --all
|
|
82
|
+
* logout --server <url> remove that scope
|
|
83
|
+
* logout --all remove everything, delete the file
|
|
84
|
+
*
|
|
85
|
+
* No silent partial sign-out, and no destructive default. The picker appears
|
|
86
|
+
* only where the answer is genuinely ambiguous, so the common one-scope case
|
|
87
|
+
* stays a single keystroke. Deleting a credential is not something to guess
|
|
88
|
+
* at: with several saved and nothing able to ask, it refuses.
|
|
89
|
+
*/
|
|
90
|
+
export declare function runLogout(opts?: LogoutOptions): Promise<LogoutResult>;
|
|
45
91
|
/** Hidden interactive prompt (no echo) via node:readline — no new dependency. */
|
|
46
92
|
export declare function promptHidden(question?: string): Promise<string>;
|
|
47
93
|
//# sourceMappingURL=login.d.ts.map
|
package/dist/commands/login.js
CHANGED
|
@@ -14,9 +14,17 @@ import { dirname } from 'node:path';
|
|
|
14
14
|
import { createInterface } from 'node:readline';
|
|
15
15
|
import { ApiKeyError, assertKeyCharset, defaultCredentialsPath, findConfigPath, loadConfig, } from '../config.js';
|
|
16
16
|
import { LionRapidApiClient } from '../api-client.js';
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The server a bare `login`/`init` targets. THE only copy of this host — a
|
|
19
|
+
* second literal in `init.ts` is how it silently drifted before.
|
|
20
|
+
*
|
|
21
|
+
* `api.lionrapid.com` (the previous value) has no DNS record, so every fresh
|
|
22
|
+
* `init` wrote an unreachable server; credentials are then saved per server
|
|
23
|
+
* URL, the next lookup misses, and the CLI blames a missing API key.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_SERVER = 'https://app.lionrapid.com';
|
|
18
26
|
export async function runLogin(opts = {}) {
|
|
19
|
-
const scope = opts.server ? 'server' : 'default';
|
|
27
|
+
const scope = opts.scope ?? (opts.server ? 'server' : 'default');
|
|
20
28
|
const server = normalizeServer(opts.server ?? repoServer(opts.cwd) ?? DEFAULT_SERVER);
|
|
21
29
|
const key = (opts.key ?? (await (opts.promptKey ?? promptHidden)())).trim();
|
|
22
30
|
if (!key)
|
|
@@ -38,24 +46,70 @@ export async function runLogin(opts = {}) {
|
|
|
38
46
|
writeCreds(path, creds);
|
|
39
47
|
return { server, scope, credentialsPath: path };
|
|
40
48
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Remove a saved credential.
|
|
51
|
+
*
|
|
52
|
+
* Bare `logout` used to remove ONLY `default` — surprising when you hold three
|
|
53
|
+
* scopes and expected to be signed out, and a silent no-op when you hold one
|
|
54
|
+
* scoped key and no default at all. The shape now (owner's decision,
|
|
55
|
+
* 2026-08-07):
|
|
56
|
+
*
|
|
57
|
+
* logout one scope saved → remove it
|
|
58
|
+
* several saved → ask which
|
|
59
|
+
* non-interactive → refuse, naming --server / --all
|
|
60
|
+
* logout --server <url> remove that scope
|
|
61
|
+
* logout --all remove everything, delete the file
|
|
62
|
+
*
|
|
63
|
+
* No silent partial sign-out, and no destructive default. The picker appears
|
|
64
|
+
* only where the answer is genuinely ambiguous, so the common one-scope case
|
|
65
|
+
* stays a single keystroke. Deleting a credential is not something to guess
|
|
66
|
+
* at: with several saved and nothing able to ask, it refuses.
|
|
67
|
+
*/
|
|
68
|
+
export async function runLogout(opts = {}) {
|
|
44
69
|
const path = opts.credentialsPath ?? defaultCredentialsPath();
|
|
70
|
+
const nothing = (scope) => ({
|
|
71
|
+
scope,
|
|
72
|
+
removed: false,
|
|
73
|
+
removedScopes: 0,
|
|
74
|
+
credentialsPath: path,
|
|
75
|
+
});
|
|
45
76
|
if (!existsSync(path))
|
|
46
|
-
return
|
|
77
|
+
return nothing(opts.all ? 'all' : (opts.server ?? 'default'));
|
|
47
78
|
const creds = readCreds(path);
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
79
|
+
const scopes = Object.keys(creds);
|
|
80
|
+
if (opts.all) {
|
|
81
|
+
if (scopes.length === 0)
|
|
82
|
+
return nothing('all');
|
|
83
|
+
rmSync(path);
|
|
84
|
+
return { scope: 'all', removed: true, removedScopes: scopes.length, credentialsPath: path };
|
|
85
|
+
}
|
|
86
|
+
const entry = opts.server
|
|
87
|
+
? normalizeServer(opts.server)
|
|
88
|
+
: scopes.length === 1
|
|
89
|
+
? scopes[0]
|
|
90
|
+
: scopes.length === 0
|
|
91
|
+
? undefined
|
|
92
|
+
: await pick(scopes, opts.choose, path);
|
|
93
|
+
if (entry === undefined)
|
|
94
|
+
return nothing(opts.server ?? 'default');
|
|
95
|
+
if (!Object.prototype.hasOwnProperty.call(creds, entry))
|
|
96
|
+
return nothing(entry);
|
|
97
|
+
delete creds[entry];
|
|
98
|
+
if (Object.keys(creds).length === 0)
|
|
99
|
+
rmSync(path);
|
|
100
|
+
else
|
|
101
|
+
writeCreds(path, creds);
|
|
102
|
+
return { scope: entry, removed: true, removedScopes: 1, credentialsPath: path };
|
|
103
|
+
}
|
|
104
|
+
/** Ask which scope to drop, or refuse when nothing can ask. */
|
|
105
|
+
async function pick(scopes, choose, path) {
|
|
106
|
+
if (!choose) {
|
|
107
|
+
throw new ApiKeyError(`${scopes.length} saved credentials in ${path} — which one?\n` +
|
|
108
|
+
`${scopes.map((s) => ` · ${s}`).join('\n')}\n` +
|
|
109
|
+
' Name one with `lionrapid logout --server <url>`, or remove them all with\n' +
|
|
110
|
+
' `lionrapid logout --all`.');
|
|
57
111
|
}
|
|
58
|
-
return
|
|
112
|
+
return choose(scopes);
|
|
59
113
|
}
|
|
60
114
|
/** Match the `config.server` normalization so scoped lookups hit exactly. */
|
|
61
115
|
function normalizeServer(server) {
|
package/dist/commands/pull.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ export interface PullFileResult {
|
|
|
26
26
|
added?: number;
|
|
27
27
|
updated?: number;
|
|
28
28
|
keptLocal?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Keys kept because the local file was edited since the last pull — the
|
|
31
|
+
* server value was NOT written over them. Absent is the normal case.
|
|
32
|
+
*/
|
|
33
|
+
conflicts?: string[];
|
|
29
34
|
/** Set when this file failed (fetch or write) — the file was not written. */
|
|
30
35
|
error?: string;
|
|
31
36
|
}
|
|
@@ -40,6 +45,14 @@ export interface PullResult {
|
|
|
40
45
|
/** True when this was a dry run — no files were written. */
|
|
41
46
|
dryRun?: boolean;
|
|
42
47
|
}
|
|
48
|
+
export interface MergeOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Bare key → the hash the server last gave us for this locale, from the
|
|
51
|
+
* lock. Absent (no lock, or a key the lock has never seen) means today's
|
|
52
|
+
* behaviour: server wins. A first pull must not look like 653 conflicts.
|
|
53
|
+
*/
|
|
54
|
+
lockTargets?: Record<string, string>;
|
|
55
|
+
}
|
|
43
56
|
/**
|
|
44
57
|
* Overlay fetched entries onto the existing local file: server wins per key,
|
|
45
58
|
* local-only entries survive, existing key order is preserved and new keys
|
|
@@ -47,11 +60,12 @@ export interface PullResult {
|
|
|
47
60
|
* has no comment channel, so replacing the entry would silently drop them.
|
|
48
61
|
* Exported for direct unit testing.
|
|
49
62
|
*/
|
|
50
|
-
export declare function mergeEntries(local: TranslationEntry[], fetched: TranslationEntry[]): {
|
|
63
|
+
export declare function mergeEntries(local: TranslationEntry[], fetched: TranslationEntry[], opts?: MergeOptions): {
|
|
51
64
|
entries: TranslationEntry[];
|
|
52
65
|
added: number;
|
|
53
66
|
updated: number;
|
|
54
67
|
keptLocal: number;
|
|
68
|
+
conflicts: string[];
|
|
55
69
|
};
|
|
56
70
|
export declare function runPull(config: LionRapidConfig, client: LionRapidApiClient, opts: PullOptions): Promise<PullResult>;
|
|
57
71
|
//# sourceMappingURL=pull.d.ts.map
|
package/dist/commands/pull.js
CHANGED
|
@@ -10,7 +10,42 @@
|
|
|
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 { saveHistory } from '../history.js';
|
|
14
|
+
import { hashSource, lockKey, readLock, writeLock } from '../lockfile.js';
|
|
13
15
|
import { assertInside, mapLimit, resolveConcurrency } from './context.js';
|
|
16
|
+
/** The lock's last-known server hashes for one namespace+locale, by bare key. */
|
|
17
|
+
function lockTargetsFor(lock, namespace, locale) {
|
|
18
|
+
const out = {};
|
|
19
|
+
const prefix = `${namespace}:`;
|
|
20
|
+
for (const [key, entry] of Object.entries(lock.entries)) {
|
|
21
|
+
if (!key.startsWith(prefix))
|
|
22
|
+
continue;
|
|
23
|
+
const hash = entry.targets?.[locale]?.hash;
|
|
24
|
+
if (hash)
|
|
25
|
+
out[key.slice(prefix.length)] = hash;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Fold a namespace's fetched rows into the lock, for one target locale.
|
|
31
|
+
*
|
|
32
|
+
* `sourceHash` is deliberately left alone: pull sees TARGET rows, and claiming
|
|
33
|
+
* to know the server's source state from them would let the next push skip a
|
|
34
|
+
* source string it never actually confirmed.
|
|
35
|
+
*/
|
|
36
|
+
function recordServerState(lock, namespace, locale, fetched, pulledAt) {
|
|
37
|
+
for (const entry of fetched) {
|
|
38
|
+
const key = lockKey(namespace, entry.key);
|
|
39
|
+
const existing = lock.entries[key] ?? { sourceHash: '' };
|
|
40
|
+
lock.entries[key] = {
|
|
41
|
+
...existing,
|
|
42
|
+
targets: {
|
|
43
|
+
...existing.targets,
|
|
44
|
+
[locale]: { hash: hashSource(entry.value), serverUpdatedAt: pulledAt },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
14
49
|
/**
|
|
15
50
|
* Overlay fetched entries onto the existing local file: server wins per key,
|
|
16
51
|
* local-only entries survive, existing key order is preserved and new keys
|
|
@@ -18,9 +53,10 @@ import { assertInside, mapLimit, resolveConcurrency } from './context.js';
|
|
|
18
53
|
* has no comment channel, so replacing the entry would silently drop them.
|
|
19
54
|
* Exported for direct unit testing.
|
|
20
55
|
*/
|
|
21
|
-
export function mergeEntries(local, fetched) {
|
|
56
|
+
export function mergeEntries(local, fetched, opts = {}) {
|
|
22
57
|
const newByKey = new Map(fetched.map((e) => [e.key, e]));
|
|
23
58
|
const entries = [];
|
|
59
|
+
const conflicts = [];
|
|
24
60
|
let updated = 0;
|
|
25
61
|
let keptLocal = 0;
|
|
26
62
|
for (const l of local) {
|
|
@@ -30,6 +66,21 @@ export function mergeEntries(local, fetched) {
|
|
|
30
66
|
entries.push(l);
|
|
31
67
|
continue;
|
|
32
68
|
}
|
|
69
|
+
// THE MISSING DIMENSION. Presence alone cannot tell a local EDIT from a
|
|
70
|
+
// local-only key; the lock's last-known server hash can. A local value
|
|
71
|
+
// that no longer matches what the server last gave us was edited here
|
|
72
|
+
// since the last pull, so taking the server's value would destroy it.
|
|
73
|
+
//
|
|
74
|
+
// The asymmetry is deliberate: losing a translator's work is worse than a
|
|
75
|
+
// stale local file, so the destructive direction needs `--force`.
|
|
76
|
+
const lastKnown = opts.lockTargets?.[l.key];
|
|
77
|
+
if (lastKnown !== undefined && hashSource(l.value) !== lastKnown) {
|
|
78
|
+
conflicts.push(l.key);
|
|
79
|
+
keptLocal += 1;
|
|
80
|
+
entries.push(l);
|
|
81
|
+
newByKey.delete(l.key);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
33
84
|
if (JSON.stringify(f.value) !== JSON.stringify(l.value))
|
|
34
85
|
updated += 1;
|
|
35
86
|
entries.push(l.comment !== undefined ? { ...f, comment: l.comment } : f);
|
|
@@ -42,7 +93,7 @@ export function mergeEntries(local, fetched) {
|
|
|
42
93
|
added += 1;
|
|
43
94
|
}
|
|
44
95
|
}
|
|
45
|
-
return { entries, added, updated, keptLocal };
|
|
96
|
+
return { entries, added, updated, keptLocal, conflicts };
|
|
46
97
|
}
|
|
47
98
|
/** Union of source keys per namespace, for "pending" reporting. */
|
|
48
99
|
function sourceKeysByNamespace(config, cwd) {
|
|
@@ -113,6 +164,12 @@ export async function runPull(config, client, opts) {
|
|
|
113
164
|
: null;
|
|
114
165
|
catalogContent.set(file, base);
|
|
115
166
|
}
|
|
167
|
+
// The lockfile records what the server last said, so the NEXT push can send
|
|
168
|
+
// only what changed and the next pull can tell a local edit from a
|
|
169
|
+
// local-only key. Merged into whatever is already there: another locale or
|
|
170
|
+
// namespace fetched on an earlier run must not be erased by this one.
|
|
171
|
+
const lock = readLock(opts.cwd);
|
|
172
|
+
const pulledAt = new Date().toISOString();
|
|
116
173
|
const locales = [];
|
|
117
174
|
for (const locale of config.targetLocales) {
|
|
118
175
|
const fileResults = [];
|
|
@@ -139,10 +196,28 @@ export async function runPull(config, client, opts) {
|
|
|
139
196
|
// wholesale overwrite.
|
|
140
197
|
let toWrite = fetched;
|
|
141
198
|
let diff;
|
|
199
|
+
let conflicts;
|
|
200
|
+
// `--force` is about to discard whatever is in this file. Keep a copy
|
|
201
|
+
// first — symmetric with `push --force`, and the only thing standing
|
|
202
|
+
// between the flag and a translator's lost work.
|
|
203
|
+
if (opts.force && !opts.dryRun && existsSync(absPath)) {
|
|
204
|
+
saveHistory(opts.cwd, {
|
|
205
|
+
direction: 'pull',
|
|
206
|
+
namespace,
|
|
207
|
+
locale,
|
|
208
|
+
reason: `pull --force replaced ${relPath} with the server's version`,
|
|
209
|
+
entries: adapter.parse(readFileSync(absPath, 'utf8')),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
142
212
|
if (!opts.force && fetched.length > 0 && existsSync(absPath)) {
|
|
143
|
-
const merged = mergeEntries(adapter.parse(readFileSync(absPath, 'utf8')), fetched
|
|
213
|
+
const merged = mergeEntries(adapter.parse(readFileSync(absPath, 'utf8')), fetched, {
|
|
214
|
+
lockTargets: lockTargetsFor(lock, namespace, locale),
|
|
215
|
+
});
|
|
144
216
|
toWrite = merged.entries;
|
|
145
217
|
diff = { added: merged.added, updated: merged.updated, keptLocal: merged.keptLocal };
|
|
218
|
+
if (merged.conflicts.length > 0) {
|
|
219
|
+
conflicts = merged.conflicts.map((key) => lockKey(namespace, key));
|
|
220
|
+
}
|
|
146
221
|
}
|
|
147
222
|
// Don't clobber an existing target with an empty result (locked/pending).
|
|
148
223
|
// Dry run computes counts but writes nothing.
|
|
@@ -154,7 +229,16 @@ export async function runPull(config, client, opts) {
|
|
|
154
229
|
flat: file.flat,
|
|
155
230
|
}));
|
|
156
231
|
}
|
|
157
|
-
|
|
232
|
+
// Record what the SERVER just said. `pull` is the only writer of
|
|
233
|
+
// `serverUpdatedAt` — push must never fabricate a server state it did
|
|
234
|
+
// not observe.
|
|
235
|
+
recordServerState(lock, namespace, locale, fetched, pulledAt);
|
|
236
|
+
fileResults.push({
|
|
237
|
+
target: relPath,
|
|
238
|
+
count: fetched.length,
|
|
239
|
+
...(diff ?? {}),
|
|
240
|
+
...(conflicts ? { conflicts } : {}),
|
|
241
|
+
});
|
|
158
242
|
}
|
|
159
243
|
catch (err) {
|
|
160
244
|
firstError ??= err;
|
|
@@ -237,6 +321,10 @@ export async function runPull(config, client, opts) {
|
|
|
237
321
|
if (all.length > 0 && all.every((f) => f.error) && firstError !== undefined) {
|
|
238
322
|
throw firstError;
|
|
239
323
|
}
|
|
324
|
+
// A dry run writes no lock for the same reason it writes no files: it did
|
|
325
|
+
// not change anything, so nothing new is known.
|
|
326
|
+
if (!opts.dryRun)
|
|
327
|
+
writeLock(opts.cwd, lock);
|
|
240
328
|
return { locales, ...(opts.dryRun ? { dryRun: true } : {}) };
|
|
241
329
|
}
|
|
242
330
|
//# 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,22 +41,72 @@ 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;
|
|
49
|
+
/**
|
|
50
|
+
* Project root for `.lionrapid/lock.json`. Omitted = no lock at all: every
|
|
51
|
+
* key is sent, exactly as before L2. Tests use it to point at a scratch dir;
|
|
52
|
+
* the CLI passes the project root.
|
|
53
|
+
*/
|
|
54
|
+
lockPath?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Send what the SERVER lacks and nothing else, asking the server rather than
|
|
57
|
+
* trusting the lock. This is the recovery for a job that was accepted (202)
|
|
58
|
+
* and then dead-lettered: the lock records those rows as sent, so only the
|
|
59
|
+
* server can say they are missing.
|
|
60
|
+
*/
|
|
61
|
+
backfillMissing?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Send everything, overwriting server values — saving the server's current
|
|
64
|
+
* source rows to `.lionrapid/history/` first, the way `pull --force` saves
|
|
65
|
+
* the local ones.
|
|
66
|
+
*/
|
|
67
|
+
force?: boolean;
|
|
21
68
|
}
|
|
22
69
|
export interface PushFileResult {
|
|
23
70
|
file: string;
|
|
24
71
|
count: number;
|
|
25
72
|
skipped?: boolean;
|
|
73
|
+
/** Keys this file already had on the server, per the lock — not re-sent. */
|
|
74
|
+
unchanged?: number;
|
|
26
75
|
/** Set when this file failed (parse or upload) — it was not (fully) pushed. */
|
|
27
76
|
error?: string;
|
|
28
77
|
}
|
|
29
78
|
export interface PushResult {
|
|
30
79
|
mode: 'source' | 'target';
|
|
80
|
+
/**
|
|
81
|
+
* Keys the CLI SENT. Not the number stored — the server queues them, so
|
|
82
|
+
* `jobs` is what says whether they landed.
|
|
83
|
+
*/
|
|
31
84
|
pushed: number;
|
|
85
|
+
/** Keys the lock says the server already has, so they were not sent again. */
|
|
86
|
+
skipped: number;
|
|
87
|
+
/**
|
|
88
|
+
* What this push is doing, split so `--dry-run` can answer the question you
|
|
89
|
+
* ran it to ask: am I ADDING strings, or overwriting ones that already
|
|
90
|
+
* exist? "2 to send" hides which two. Source mode only.
|
|
91
|
+
*/
|
|
92
|
+
diff?: {
|
|
93
|
+
added: number;
|
|
94
|
+
changed: number;
|
|
95
|
+
unchanged: number;
|
|
96
|
+
};
|
|
32
97
|
files: PushFileResult[];
|
|
98
|
+
/** Jobs the server queued for this push (source mode only; empty on a dry run). */
|
|
99
|
+
jobs: PushedJob[];
|
|
33
100
|
/** True when `--publish` was requested but no editor token was available. */
|
|
34
101
|
publishSkipped?: boolean;
|
|
35
102
|
/** True when this was a dry run — nothing was sent to the server. */
|
|
36
103
|
dryRun?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Whether the config's `projectId` was checked against the key's project.
|
|
106
|
+
* `unavailable` means the server disclosed none — the guard could not arm,
|
|
107
|
+
* which is never a reason to block a push.
|
|
108
|
+
*/
|
|
109
|
+
projectCheck?: 'ok' | 'unavailable';
|
|
37
110
|
}
|
|
38
111
|
export declare function runPush(config: LionRapidConfig, client: LionRapidApiClient, opts: PushOptions): Promise<PushResult>;
|
|
39
112
|
//# sourceMappingURL=push.d.ts.map
|