@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/api-client.js
CHANGED
|
@@ -10,11 +10,21 @@
|
|
|
10
10
|
*/
|
|
11
11
|
/** Default number of retries for transient failures. */
|
|
12
12
|
export const DEFAULT_RETRIES = 3;
|
|
13
|
+
/** Default per-attempt request timeout. */
|
|
14
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
15
|
/** Base backoff in ms; grows exponentially per attempt, capped at {@link MAX_BACKOFF_MS}. */
|
|
14
16
|
const BASE_BACKOFF_MS = 300;
|
|
15
17
|
const MAX_BACKOFF_MS = 10_000;
|
|
16
18
|
/** Cap on an honored `Retry-After` so a hostile/misconfigured header can't hang the CLI. */
|
|
17
19
|
const MAX_RETRY_AFTER_MS = 30_000;
|
|
20
|
+
/**
|
|
21
|
+
* Wait for a 429 that carries no `Retry-After` header. tm-server's rate-limit
|
|
22
|
+
* responses send none, and its limits are per-minute windows — the jittered
|
|
23
|
+
* sub-second backoff below can never outlive one, so without this floor every
|
|
24
|
+
* 429 exhausted its retries and was effectively fatal (e.g. a push past the
|
|
25
|
+
* 30/min sync limit died at namespace 31).
|
|
26
|
+
*/
|
|
27
|
+
const RATE_LIMIT_FALLBACK_MS = 30_000;
|
|
18
28
|
/** Transient statuses worth retrying: rate-limit (429) and any 5xx. */
|
|
19
29
|
function isRetryableStatus(status) {
|
|
20
30
|
return status === 429 || status >= 500;
|
|
@@ -69,6 +79,18 @@ export class ApiError extends Error {
|
|
|
69
79
|
return this.status >= 500;
|
|
70
80
|
}
|
|
71
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* True when a fetch rejection (or anything in its `cause` chain) reports a
|
|
84
|
+
* redirect — with `redirect: 'error'` pinned, that means the server answered
|
|
85
|
+
* with a 3xx we refused to follow.
|
|
86
|
+
*/
|
|
87
|
+
function isRedirectFailure(err) {
|
|
88
|
+
for (let e = err; e instanceof Error; e = e.cause) {
|
|
89
|
+
if (/redirect/i.test(e.message))
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
72
94
|
/**
|
|
73
95
|
* Thrown when the request never reached the server — DNS/connection/TLS failure
|
|
74
96
|
* or an abort. Distinct from {@link ApiError} (which means the server answered
|
|
@@ -76,13 +98,59 @@ export class ApiError extends Error {
|
|
|
76
98
|
*/
|
|
77
99
|
export class NetworkError extends Error {
|
|
78
100
|
constructor(method, path, cause) {
|
|
79
|
-
|
|
101
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
102
|
+
super(isRedirectFailure(cause)
|
|
103
|
+
? `tm-server ${method} ${path} → unexpected redirect (refused, so the API key stays on the configured server): ${detail}`
|
|
104
|
+
: `tm-server ${method} ${path} → network error: ${detail}`, { cause });
|
|
80
105
|
this.name = 'NetworkError';
|
|
81
106
|
}
|
|
82
107
|
}
|
|
83
108
|
function compositeKey(locale, namespace, key) {
|
|
84
109
|
return `${locale}:${namespace}:${key}`;
|
|
85
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Coerce a namespace payload into flat `dotted.key` → {@link ContentUnit}.
|
|
113
|
+
*
|
|
114
|
+
* The endpoint takes flat keys on write but answers **nested** on read
|
|
115
|
+
* (`{"nav": {"cart": …}}` for a pushed `nav.cart`), because the SDK resolves
|
|
116
|
+
* nested paths. Every CLI adapter is built on flat dotted keys, so the tree
|
|
117
|
+
* has to be flattened back here or whole subtrees silently vanish. The leaf
|
|
118
|
+
* rule mirrors the server's: a string, or an object carrying `text`, is a
|
|
119
|
+
* value; any other object is a container to descend into. Bare strings are
|
|
120
|
+
* wrapped, a missing `styles` defaults to `{}`, and a genuinely unusable leaf
|
|
121
|
+
* is dropped with a warning rather than crashing the run.
|
|
122
|
+
*/
|
|
123
|
+
export function normalizeTranslations(raw) {
|
|
124
|
+
const out = {};
|
|
125
|
+
const walk = (node, prefix) => {
|
|
126
|
+
if (node === null || typeof node !== 'object') {
|
|
127
|
+
console.error(`warning: skipped key "${prefix}" — server returned no usable text`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const [key, value] of Object.entries(node)) {
|
|
131
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
132
|
+
if (typeof value === 'string') {
|
|
133
|
+
out[path] = { text: value, styles: {} };
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (value !== null && typeof value === 'object') {
|
|
137
|
+
const unit = value;
|
|
138
|
+
if (typeof unit.text === 'string') {
|
|
139
|
+
out[path] = { text: unit.text, styles: (unit.styles ?? {}) };
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
walk(value, path); // a container, not a value
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
console.error(`warning: skipped key "${path}" — server returned no usable text`);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
if (raw === null || typeof raw !== 'object')
|
|
150
|
+
return {};
|
|
151
|
+
walk(raw, '');
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
86
154
|
export class LionRapidApiClient {
|
|
87
155
|
baseUrl;
|
|
88
156
|
apiKey;
|
|
@@ -90,6 +158,7 @@ export class LionRapidApiClient {
|
|
|
90
158
|
fetchImpl;
|
|
91
159
|
retries;
|
|
92
160
|
sleep;
|
|
161
|
+
timeoutMs;
|
|
93
162
|
constructor(opts) {
|
|
94
163
|
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
|
95
164
|
this.apiKey = opts.apiKey;
|
|
@@ -98,6 +167,7 @@ export class LionRapidApiClient {
|
|
|
98
167
|
this.retries = Math.max(0, opts.retries ?? DEFAULT_RETRIES);
|
|
99
168
|
this.sleep =
|
|
100
169
|
opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
170
|
+
this.timeoutMs = Math.max(1, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
101
171
|
}
|
|
102
172
|
/** True when this client holds an editor token and can publish (mark-ready). */
|
|
103
173
|
get canPublish() {
|
|
@@ -108,6 +178,21 @@ export class LionRapidApiClient {
|
|
|
108
178
|
const res = (await this.request('GET', '/api/integrations/editor/workflow'));
|
|
109
179
|
return { isPmAdmin: !!res?.is_pm_admin };
|
|
110
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* The project's dashboard-configured locales (for `init` prefill).
|
|
183
|
+
* GET /api/integrations/project-config — flat `{ defaultLocale, languages }`;
|
|
184
|
+
* `languages` includes the default locale. Malformed bodies normalize to
|
|
185
|
+
* empty values rather than throwing.
|
|
186
|
+
*/
|
|
187
|
+
async getProjectConfig() {
|
|
188
|
+
const res = (await this.request('GET', '/api/integrations/project-config'));
|
|
189
|
+
return {
|
|
190
|
+
defaultLocale: typeof res?.defaultLocale === 'string' ? res.defaultLocale : '',
|
|
191
|
+
languages: Array.isArray(res?.languages)
|
|
192
|
+
? res.languages.filter((l) => typeof l === 'string')
|
|
193
|
+
: [],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
111
196
|
/**
|
|
112
197
|
* Register source-row text for `sourceLocale`. Mirrors what the edge does on
|
|
113
198
|
* first visit when it encounters a missing key. POST /api/integrations/sync/batch.
|
|
@@ -172,7 +257,7 @@ export class LionRapidApiClient {
|
|
|
172
257
|
}
|
|
173
258
|
const data = (await res.json());
|
|
174
259
|
return {
|
|
175
|
-
translations: data.translations
|
|
260
|
+
translations: normalizeTranslations(data.translations),
|
|
176
261
|
version: data.meta?.version != null ? String(data.meta.version) : undefined,
|
|
177
262
|
notModified: false,
|
|
178
263
|
locked: false,
|
|
@@ -214,10 +299,22 @@ export class LionRapidApiClient {
|
|
|
214
299
|
let res;
|
|
215
300
|
let netErr;
|
|
216
301
|
try {
|
|
217
|
-
|
|
302
|
+
// `redirect: 'error'` pins every request to the configured host: a 3xx
|
|
303
|
+
// fails loudly instead of relying on the runtime to strip the
|
|
304
|
+
// Authorization header on a cross-origin redirect. The timeout signal
|
|
305
|
+
// is per-attempt, so every retry gets a fresh budget.
|
|
306
|
+
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
307
|
+
...init,
|
|
308
|
+
redirect: 'error',
|
|
309
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
310
|
+
});
|
|
218
311
|
}
|
|
219
312
|
catch (err) {
|
|
220
|
-
|
|
313
|
+
const isTimeout = err instanceof Error && err.name === 'TimeoutError';
|
|
314
|
+
netErr = new NetworkError(method, path, isTimeout ? new Error(`timed out after ${this.timeoutMs}ms`, { cause: err }) : err);
|
|
315
|
+
// A refused redirect is the server's answer, not a transient failure.
|
|
316
|
+
if (isRedirectFailure(err))
|
|
317
|
+
throw netErr;
|
|
221
318
|
}
|
|
222
319
|
// A non-transient response is the answer — let the caller interpret it.
|
|
223
320
|
if (res && !isRetryableStatus(res.status)) {
|
|
@@ -240,8 +337,11 @@ export class LionRapidApiClient {
|
|
|
240
337
|
return res;
|
|
241
338
|
throw netErr;
|
|
242
339
|
}
|
|
243
|
-
|
|
244
|
-
|
|
340
|
+
// A 429 is a rate-limit window, not a blip: honor `Retry-After` when the
|
|
341
|
+
// server sent one, else wait window-sized instead of jittering.
|
|
342
|
+
const delay = res?.status === 429
|
|
343
|
+
? (parseRetryAfter(res.headers.get('retry-after'), Date.now()) ?? RATE_LIMIT_FALLBACK_MS)
|
|
344
|
+
: backoffMs(attempt, Math.random);
|
|
245
345
|
await this.sleep(delay);
|
|
246
346
|
}
|
|
247
347
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* PO) with the LionRapid backend.
|
|
5
5
|
*
|
|
6
6
|
* This file wires up the command surface. Each command's behaviour lives in
|
|
7
|
-
* `src/commands
|
|
8
|
-
* yet" notice and exit non-zero so scripts never silently succeed.
|
|
7
|
+
* `src/commands/*`.
|
|
9
8
|
*/
|
|
10
9
|
import { Command } from 'commander';
|
|
11
10
|
/**
|
package/dist/cli.js
CHANGED
|
@@ -4,19 +4,20 @@
|
|
|
4
4
|
* PO) with the LionRapid backend.
|
|
5
5
|
*
|
|
6
6
|
* This file wires up the command surface. Each command's behaviour lives in
|
|
7
|
-
* `src/commands
|
|
8
|
-
* yet" notice and exit non-zero so scripts never silently succeed.
|
|
7
|
+
* `src/commands/*`.
|
|
9
8
|
*/
|
|
10
9
|
import { Command } from 'commander';
|
|
11
10
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
12
|
-
import { dirname, join } from 'node:path';
|
|
11
|
+
import { basename, dirname, join } from 'node:path';
|
|
12
|
+
import { createInterface } from 'node:readline';
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
|
-
import { ConfigError, ApiKeyError } from './config.js';
|
|
15
|
-
import { ApiError, NetworkError } from './api-client.js';
|
|
14
|
+
import { ConfigError, ApiKeyError, resolveApiKey, listCredentialServers, findConfigPath, loadConfig, } from './config.js';
|
|
15
|
+
import { ApiError, NetworkError, LionRapidApiClient } from './api-client.js';
|
|
16
16
|
import { ExitCode, exitCodeForError } from './exit-codes.js';
|
|
17
17
|
import { emitJson, errorEnvelope } from './output.js';
|
|
18
18
|
import { loadContext, loadOfflineContext } from './commands/context.js';
|
|
19
|
-
import { runInit } from './commands/init.js';
|
|
19
|
+
import { runInit, fetchLocaleDefaults, detectLocalesOnDisk } from './commands/init.js';
|
|
20
|
+
import { runLogin, runLogout, promptHidden, DEFAULT_SERVER } from './commands/login.js';
|
|
20
21
|
import { runPush } from './commands/push.js';
|
|
21
22
|
import { runPull } from './commands/pull.js';
|
|
22
23
|
import { runValidate } from './commands/validate.js';
|
|
@@ -62,6 +63,21 @@ function fail(err, ctx = {}) {
|
|
|
62
63
|
}
|
|
63
64
|
process.exit(exitCodeForError(err));
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* One-line interactive prompt on stderr with a default. Non-TTY (CI, pipes)
|
|
68
|
+
* never blocks — it returns the default immediately.
|
|
69
|
+
*/
|
|
70
|
+
function askText(question, def) {
|
|
71
|
+
if (!process.stdin.isTTY)
|
|
72
|
+
return Promise.resolve(def);
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
75
|
+
rl.question(`${question} [${def}]: `, (answer) => {
|
|
76
|
+
rl.close();
|
|
77
|
+
resolve(answer.trim() || def);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
65
81
|
/**
|
|
66
82
|
* Build the commander program. Exported (rather than parsed inline) so tests
|
|
67
83
|
* can construct and inspect the program without executing it.
|
|
@@ -71,7 +87,14 @@ export function buildProgram() {
|
|
|
71
87
|
program
|
|
72
88
|
.name('lionrapid')
|
|
73
89
|
.description('Sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend')
|
|
74
|
-
.version(readVersion(), '-v, --version', 'output the current version')
|
|
90
|
+
.version(readVersion(), '-v, --version', 'output the current version')
|
|
91
|
+
.option('--timeout <ms>', 'per-attempt request timeout in ms (default 30000)')
|
|
92
|
+
// Global flag → env, so loadContext picks it up uniformly in every command.
|
|
93
|
+
.hook('preAction', (thisCommand) => {
|
|
94
|
+
const timeout = thisCommand.opts().timeout;
|
|
95
|
+
if (timeout)
|
|
96
|
+
process.env['LIONRAPID_TIMEOUT'] = timeout;
|
|
97
|
+
});
|
|
75
98
|
program
|
|
76
99
|
.command('init')
|
|
77
100
|
.description('Scaffold a lionrapid.yml config in the current project')
|
|
@@ -79,24 +102,190 @@ export function buildProgram() {
|
|
|
79
102
|
.option('--server <url>', 'API server URL')
|
|
80
103
|
.option('--source <locale>', 'source locale')
|
|
81
104
|
.option('--targets <list>', 'comma-separated target locales')
|
|
105
|
+
.option('--key <key>', 'API key used to prefill locales from the project config')
|
|
82
106
|
.option('--force', 'overwrite an existing lionrapid.yml')
|
|
83
|
-
.action((options) => {
|
|
107
|
+
.action(async (options) => {
|
|
84
108
|
try {
|
|
109
|
+
// Reuse what's already known before asking: the repo's own config,
|
|
110
|
+
// then the server you logged into. Re-running `init` should not make
|
|
111
|
+
// you re-type things you've already told the CLI.
|
|
112
|
+
const existingServer = (() => {
|
|
113
|
+
const path = findConfigPath(process.cwd());
|
|
114
|
+
try {
|
|
115
|
+
return path ? loadConfig(path).server : undefined;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
const loggedIn = listCredentialServers();
|
|
122
|
+
const known = existingServer ?? (loggedIn.length === 1 ? loggedIn[0] : undefined);
|
|
123
|
+
// Only ask when there's genuinely nothing to go on. After a `login`,
|
|
124
|
+
// `lionrapid init` should be a single silent command.
|
|
125
|
+
const server = options.server ?? known ?? (await askText('Server URL', DEFAULT_SERVER));
|
|
126
|
+
// The project field is a local label (the API key selects the real
|
|
127
|
+
// project) — the directory name is a better default than a question.
|
|
128
|
+
const project = options.project ?? basename(process.cwd());
|
|
129
|
+
// Locale resolution ladder: explicit flags → the project's server
|
|
130
|
+
// config → locales already present in the repo → static placeholders.
|
|
131
|
+
// Every rung degrades quietly, so init still works fully offline.
|
|
132
|
+
let source = options.source;
|
|
133
|
+
let targets = options.targets?.split(',').map((s) => s.trim());
|
|
134
|
+
const notes = [];
|
|
135
|
+
if (!source || !targets) {
|
|
136
|
+
let key = options.key;
|
|
137
|
+
if (!key) {
|
|
138
|
+
try {
|
|
139
|
+
key = resolveApiKey({ server });
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// no stored key for this server — prefill stays optional
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!key && process.stdin.isTTY) {
|
|
146
|
+
key =
|
|
147
|
+
(await promptHidden('API key to prefill locales (Enter to skip): ')).trim() ||
|
|
148
|
+
undefined;
|
|
149
|
+
}
|
|
150
|
+
let serverStatus = 'no-key';
|
|
151
|
+
let serverTargets;
|
|
152
|
+
if (key) {
|
|
153
|
+
const client = new LionRapidApiClient({
|
|
154
|
+
baseUrl: server,
|
|
155
|
+
apiKey: key,
|
|
156
|
+
retries: 0,
|
|
157
|
+
timeoutMs: 5000,
|
|
158
|
+
});
|
|
159
|
+
const prefill = await fetchLocaleDefaults(client);
|
|
160
|
+
serverStatus = prefill.status;
|
|
161
|
+
if (prefill.status === 'ok') {
|
|
162
|
+
source ??= prefill.sourceLocale;
|
|
163
|
+
serverTargets = prefill.targetLocales;
|
|
164
|
+
targets ??= prefill.targetLocales;
|
|
165
|
+
}
|
|
166
|
+
else if (prefill.status === 'no-targets') {
|
|
167
|
+
source ??= prefill.sourceLocale; // the source locale IS real
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const onDisk = detectLocalesOnDisk(process.cwd(), source ?? 'en');
|
|
171
|
+
if (serverStatus === 'ok' && serverTargets) {
|
|
172
|
+
notes.push(`locales from ${server}: ${source} → ${serverTargets.join(', ')}`);
|
|
173
|
+
// The project decides what actually gets delivered — a locale
|
|
174
|
+
// that exists only on disk is a real mismatch, worth saying out
|
|
175
|
+
// loud rather than silently merging into the scaffold.
|
|
176
|
+
const extra = onDisk.filter((l) => !serverTargets.includes(l));
|
|
177
|
+
if (extra.length > 0) {
|
|
178
|
+
notes.push(`note: ${extra.join(', ')} present in your files but not configured on the project —\n` +
|
|
179
|
+
' add them in the dashboard, or pull will never fill those files.');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (onDisk.length > 0) {
|
|
183
|
+
targets ??= onDisk;
|
|
184
|
+
notes.push(serverStatus === 'no-targets'
|
|
185
|
+
? `⚠ the project has no target languages configured (source: ${source}) — used ${onDisk.join(', ')} from the files in your repo.\n` +
|
|
186
|
+
' Add them in the dashboard, then re-run `lionrapid init`.'
|
|
187
|
+
: `locales from your files: ${source ?? 'en'} → ${onDisk.join(', ')}`);
|
|
188
|
+
}
|
|
189
|
+
else if (serverStatus === 'no-targets') {
|
|
190
|
+
// Nothing invented: an empty targetLocales is honest, and the
|
|
191
|
+
// config stays invalid until the real languages exist.
|
|
192
|
+
notes.push(`⚠ the project has no target languages configured (source: ${source}), and none\n` +
|
|
193
|
+
' were found in your files — targetLocales is empty and the config is not\n' +
|
|
194
|
+
' usable yet. Add your languages in the dashboard, then re-run `lionrapid init`.');
|
|
195
|
+
}
|
|
196
|
+
else if (serverStatus === 'unavailable') {
|
|
197
|
+
notes.push('⚠ could not reach the project config — targetLocales left empty.\n' +
|
|
198
|
+
' Fill it in by hand, or re-run `lionrapid init` once the server is reachable.');
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
notes.push('⚠ no API key, so no project config to read — targetLocales left empty.\n' +
|
|
202
|
+
' Run `lionrapid login`, then re-run `lionrapid init`.');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
85
205
|
const res = runInit(process.cwd(), {
|
|
86
|
-
project
|
|
87
|
-
server
|
|
88
|
-
sourceLocale:
|
|
89
|
-
targetLocales:
|
|
206
|
+
project,
|
|
207
|
+
server,
|
|
208
|
+
sourceLocale: source,
|
|
209
|
+
targetLocales: targets,
|
|
90
210
|
force: options.force,
|
|
91
211
|
});
|
|
92
|
-
|
|
212
|
+
if (res.created || options.force) {
|
|
213
|
+
console.log(`✓ wrote ${res.configPath}`);
|
|
214
|
+
}
|
|
215
|
+
else if (res.applied.length > 0) {
|
|
216
|
+
console.log(`✓ updated ${res.configPath}`);
|
|
217
|
+
for (const c of res.applied)
|
|
218
|
+
console.log(` + ${c.path}: ${c.to}`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
console.log(`✓ ${res.configPath} is already up to date`);
|
|
222
|
+
}
|
|
93
223
|
console.log(` detected: ${res.detected.stacks.join(', ')}`);
|
|
94
|
-
|
|
224
|
+
for (const note of notes)
|
|
225
|
+
console.log(` ${note}`);
|
|
226
|
+
if (res.conflicts.length > 0) {
|
|
227
|
+
// Never silently overwrite a value the developer set. Show the
|
|
228
|
+
// difference and let them resolve it in the file.
|
|
229
|
+
console.log(` ⚠ ${res.conflicts.length} value(s) differ from the project — left as-is:`);
|
|
230
|
+
for (const c of res.conflicts) {
|
|
231
|
+
console.log(` ${c.path}`);
|
|
232
|
+
console.log(` yours: ${c.from}`);
|
|
233
|
+
console.log(` project: ${c.to}`);
|
|
234
|
+
}
|
|
235
|
+
console.log(' Edit lionrapid.yml to resolve, or re-run with --force to take the');
|
|
236
|
+
console.log(' project values wholesale.');
|
|
237
|
+
}
|
|
238
|
+
const ready = res.conflicts.length === 0 && (targets?.length ?? 0) > 0;
|
|
239
|
+
console.log(ready
|
|
240
|
+
? ' next: `lionrapid doctor` to check the setup, then `lionrapid push`.'
|
|
241
|
+
: ' next: fix the above in lionrapid.yml, then run `lionrapid doctor`.');
|
|
95
242
|
}
|
|
96
243
|
catch (err) {
|
|
97
244
|
fail(err);
|
|
98
245
|
}
|
|
99
246
|
});
|
|
247
|
+
program
|
|
248
|
+
.command('login')
|
|
249
|
+
.description('Verify an API key against the server and save it to ~/.lionrapid/credentials')
|
|
250
|
+
.option('--server <url>', 'save the key scoped to this server URL (default: save as "default")')
|
|
251
|
+
.option('--key <key>', 'the API key (omit for a hidden interactive prompt)')
|
|
252
|
+
.option('--json', 'emit the result as JSON on stdout (prose to stderr)')
|
|
253
|
+
.action(async (options) => {
|
|
254
|
+
try {
|
|
255
|
+
const res = await runLogin({
|
|
256
|
+
server: options.server,
|
|
257
|
+
key: options.key,
|
|
258
|
+
cwd: process.cwd(),
|
|
259
|
+
});
|
|
260
|
+
const out = options.json ? console.error : console.log;
|
|
261
|
+
out(`✓ key verified against ${res.server}`);
|
|
262
|
+
out(` saved ${res.scope === 'server' ? `for ${res.server}` : 'as default'} → ${res.credentialsPath}`);
|
|
263
|
+
// Never echo the key — the envelope carries scope + server only.
|
|
264
|
+
if (options.json)
|
|
265
|
+
emitJson('login', true, { server: res.server, scope: res.scope });
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
fail(err, { json: options.json, command: 'login' });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
program
|
|
272
|
+
.command('logout')
|
|
273
|
+
.description('Remove a saved API key from ~/.lionrapid/credentials')
|
|
274
|
+
.option('--server <url>', 'remove the key scoped to this server URL (default: "default")')
|
|
275
|
+
.option('--json', 'emit the result as JSON on stdout (prose to stderr)')
|
|
276
|
+
.action((options) => {
|
|
277
|
+
try {
|
|
278
|
+
const res = runLogout({ server: options.server });
|
|
279
|
+
const out = options.json ? console.error : console.log;
|
|
280
|
+
const what = res.scope === 'server' ? `key for ${options.server}` : 'default key';
|
|
281
|
+
out(res.removed ? `✓ removed ${what}` : `· no ${what} was saved`);
|
|
282
|
+
if (options.json)
|
|
283
|
+
emitJson('logout', true, { scope: res.scope, removed: res.removed });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
fail(err, { json: options.json, command: 'logout' });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
100
289
|
program
|
|
101
290
|
.command('push')
|
|
102
291
|
.description('Upload source strings to the LionRapid backend')
|
|
@@ -118,14 +307,25 @@ export function buildProgram() {
|
|
|
118
307
|
const tag = res.dryRun ? '[dry-run] would push' : '✓ pushed';
|
|
119
308
|
out(`${tag} ${res.pushed} ${res.mode} string(s) across ${res.files.length} file(s)`);
|
|
120
309
|
for (const f of res.files) {
|
|
310
|
+
if (f.error) {
|
|
311
|
+
out(` · ${f.file}: ✖ ${f.error}`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
121
314
|
out(` ${f.skipped ? '· skipped (missing)' : '·'} ${f.file}: ${f.count}`);
|
|
122
315
|
}
|
|
316
|
+
const failed = res.files.filter((f) => f.error);
|
|
317
|
+
if (failed.length > 0) {
|
|
318
|
+
out(`⚠ ${failed.length} file(s) failed — the rest were pushed. Re-run push to retry\n` +
|
|
319
|
+
' the failures (already-pushed strings are simply upserted again).');
|
|
320
|
+
}
|
|
123
321
|
if (res.publishSkipped) {
|
|
124
322
|
out('⚠ uploaded as drafts but not published — publishing needs an editor session token.\n' +
|
|
125
323
|
' Set LIONRAPID_EDITOR_TOKEN, or publish in the dashboard.');
|
|
126
324
|
}
|
|
127
325
|
if (options.json)
|
|
128
|
-
emitJson('push',
|
|
326
|
+
emitJson('push', failed.length === 0, res);
|
|
327
|
+
if (failed.length > 0)
|
|
328
|
+
process.exit(ExitCode.PARTIAL);
|
|
129
329
|
}
|
|
130
330
|
catch (err) {
|
|
131
331
|
fail(err, { json: options.json, command: 'push' });
|
|
@@ -137,6 +337,7 @@ export function buildProgram() {
|
|
|
137
337
|
.option('-c, --config <path>', 'path to lionrapid.yml')
|
|
138
338
|
.option('--types', 'regenerate TypeScript types after pulling')
|
|
139
339
|
.option('--dry-run', 'fetch + count what would be written, but write no files')
|
|
340
|
+
.option('--force', 'overwrite target files wholesale instead of merging (drops local-only keys)')
|
|
140
341
|
.option('--json', 'emit the result as JSON on stdout (prose to stderr)')
|
|
141
342
|
.action(async (options) => {
|
|
142
343
|
try {
|
|
@@ -144,6 +345,7 @@ export function buildProgram() {
|
|
|
144
345
|
const res = await runPull(ctx.config, ctx.client, {
|
|
145
346
|
cwd: ctx.cwd,
|
|
146
347
|
dryRun: options.dryRun,
|
|
348
|
+
force: options.force,
|
|
147
349
|
onProgress: (done, total) => {
|
|
148
350
|
// Interactive feedback only — never pollute CI logs or --json stdout.
|
|
149
351
|
if (process.stderr.isTTY && total > 1) {
|
|
@@ -159,16 +361,29 @@ export function buildProgram() {
|
|
|
159
361
|
const pending = loc.pending > 0 ? ` (${loc.pending} pending)` : '';
|
|
160
362
|
out(`${mark} ${loc.locale}${pending}`);
|
|
161
363
|
for (const f of loc.files) {
|
|
162
|
-
|
|
364
|
+
if (f.error) {
|
|
365
|
+
out(` · ${f.target}: ✖ ${f.error}`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const diff = f.added !== undefined
|
|
369
|
+
? ` (+${f.added} new, ~${f.updated} updated, ${f.keptLocal} local kept)`
|
|
370
|
+
: '';
|
|
371
|
+
out(` · ${f.target}: ${f.count}${diff}${res.dryRun ? ' (not written)' : ''}`);
|
|
163
372
|
}
|
|
164
373
|
}
|
|
374
|
+
const failed = res.locales.flatMap((l) => l.files).filter((f) => f.error);
|
|
375
|
+
if (failed.length > 0) {
|
|
376
|
+
out(`⚠ ${failed.length} file(s) failed — the successful files were still written`);
|
|
377
|
+
}
|
|
165
378
|
let types;
|
|
166
379
|
if ((options.types || ctx.config.generateTypes) && !options.dryRun) {
|
|
167
380
|
types = runTypesGenerate(ctx.config, { cwd: ctx.cwd });
|
|
168
381
|
out(`✓ types: ${types.keys} key(s) → ${types.output}`);
|
|
169
382
|
}
|
|
170
383
|
if (options.json)
|
|
171
|
-
emitJson('pull',
|
|
384
|
+
emitJson('pull', failed.length === 0, { ...res, types });
|
|
385
|
+
if (failed.length > 0)
|
|
386
|
+
process.exit(ExitCode.PARTIAL);
|
|
172
387
|
}
|
|
173
388
|
catch (err) {
|
|
174
389
|
fail(err, { json: options.json, command: 'pull' });
|
|
@@ -179,19 +394,23 @@ export function buildProgram() {
|
|
|
179
394
|
.description('Check local files against the backend for missing/extra keys')
|
|
180
395
|
.option('-c, --config <path>', 'path to lionrapid.yml')
|
|
181
396
|
.option('--ci', 'exit non-zero if any issue is found')
|
|
397
|
+
.option('--allow-locked', 'treat locked (unapproved) namespaces as a warning, not a failure')
|
|
182
398
|
.option('--json', 'emit the result as JSON on stdout (prose to stderr)')
|
|
183
399
|
.action(async (options) => {
|
|
184
400
|
try {
|
|
185
401
|
const ctx = loadContext({ config: options.config });
|
|
186
|
-
const res = await runValidate(ctx.config, ctx.client, {
|
|
402
|
+
const res = await runValidate(ctx.config, ctx.client, {
|
|
403
|
+
cwd: ctx.cwd,
|
|
404
|
+
allowLocked: options.allowLocked,
|
|
405
|
+
});
|
|
187
406
|
const out = options.json ? console.error : console.log;
|
|
188
|
-
if (res.
|
|
407
|
+
if (res.issues.length === 0) {
|
|
189
408
|
out(`✓ all ${res.sourceKeys} source key(s) translated across target locales`);
|
|
190
409
|
}
|
|
191
410
|
else {
|
|
192
|
-
out(
|
|
411
|
+
out(`${res.ok ? '⚠' : '✖'} ${res.issues.length} issue(s):`);
|
|
193
412
|
for (const i of res.issues) {
|
|
194
|
-
out(` [${i.type}] ${i.locale}/${i.namespace} ${i.key}`);
|
|
413
|
+
out(` [${i.type}] ${i.locale}/${i.namespace}${i.key ? ` ${i.key}` : ''}`);
|
|
195
414
|
}
|
|
196
415
|
}
|
|
197
416
|
if (options.json)
|
|
@@ -22,8 +22,17 @@ export interface OfflineContext {
|
|
|
22
22
|
export declare function loadOfflineContext(opts?: ContextOptions): OfflineContext;
|
|
23
23
|
/** Load config + resolve the API key + build the client — for networked commands. */
|
|
24
24
|
export declare function loadContext(opts?: ContextOptions): CliContext;
|
|
25
|
+
/** Read LIONRAPID_TIMEOUT (a positive integer, ms) if set; otherwise the client default. */
|
|
26
|
+
export declare function resolveTimeout(): number | undefined;
|
|
25
27
|
/** Read LIONRAPID_RETRIES (a non-negative integer) if set; otherwise the client default. */
|
|
26
28
|
export declare function resolveRetries(): number | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve `rel` against `cwd`, refusing paths that escape it. Every
|
|
31
|
+
* config-derived file *write* goes through this — defense-in-depth so a
|
|
32
|
+
* `../`-authored target/output in lionrapid.yml can't write outside the
|
|
33
|
+
* project directory. Returns the absolute path.
|
|
34
|
+
*/
|
|
35
|
+
export declare function assertInside(cwd: string, rel: string): string;
|
|
27
36
|
export declare function chunk<T>(items: T[], size: number): T[][];
|
|
28
37
|
/** Default parallel in-flight requests for fan-out work (pull). */
|
|
29
38
|
export declare const DEFAULT_CONCURRENCY = 6;
|
package/dist/commands/context.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* build the API client. Kept separate from the commander wiring so command
|
|
4
4
|
* logic can be driven with a fake client in tests.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
7
|
+
import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, ConfigError, } from '../config.js';
|
|
7
8
|
import { LionRapidApiClient } from '../api-client.js';
|
|
8
9
|
/** Load + validate config only (no API key) — for offline commands like `types`. */
|
|
9
10
|
export function loadOfflineContext(opts = {}) {
|
|
@@ -12,7 +13,9 @@ export function loadOfflineContext(opts = {}) {
|
|
|
12
13
|
if (!path) {
|
|
13
14
|
throw new ConfigError('No lionrapid.yml found in this directory. Run `lionrapid init` to create one.');
|
|
14
15
|
}
|
|
15
|
-
|
|
16
|
+
const config = loadConfig(path);
|
|
17
|
+
// Glob entries expand here, once — commands only ever see concrete files.
|
|
18
|
+
return { config: { ...config, files: expandGlobEntries(config.files, cwd) }, cwd };
|
|
16
19
|
}
|
|
17
20
|
/** Load config + resolve the API key + build the client — for networked commands. */
|
|
18
21
|
export function loadContext(opts = {}) {
|
|
@@ -25,9 +28,18 @@ export function loadContext(opts = {}) {
|
|
|
25
28
|
apiKey,
|
|
26
29
|
editorToken,
|
|
27
30
|
retries: resolveRetries(),
|
|
31
|
+
timeoutMs: resolveTimeout(),
|
|
28
32
|
});
|
|
29
33
|
return { config, client, cwd };
|
|
30
34
|
}
|
|
35
|
+
/** Read LIONRAPID_TIMEOUT (a positive integer, ms) if set; otherwise the client default. */
|
|
36
|
+
export function resolveTimeout() {
|
|
37
|
+
const raw = process.env['LIONRAPID_TIMEOUT']?.trim();
|
|
38
|
+
if (!raw)
|
|
39
|
+
return undefined;
|
|
40
|
+
const n = Number(raw);
|
|
41
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
42
|
+
}
|
|
31
43
|
/** Read LIONRAPID_RETRIES (a non-negative integer) if set; otherwise the client default. */
|
|
32
44
|
export function resolveRetries() {
|
|
33
45
|
const raw = process.env['LIONRAPID_RETRIES']?.trim();
|
|
@@ -36,6 +48,20 @@ export function resolveRetries() {
|
|
|
36
48
|
const n = Number(raw);
|
|
37
49
|
return Number.isInteger(n) && n >= 0 ? n : undefined;
|
|
38
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve `rel` against `cwd`, refusing paths that escape it. Every
|
|
53
|
+
* config-derived file *write* goes through this — defense-in-depth so a
|
|
54
|
+
* `../`-authored target/output in lionrapid.yml can't write outside the
|
|
55
|
+
* project directory. Returns the absolute path.
|
|
56
|
+
*/
|
|
57
|
+
export function assertInside(cwd, rel) {
|
|
58
|
+
const abs = resolve(cwd, rel);
|
|
59
|
+
const r = relative(cwd, abs);
|
|
60
|
+
if (r.startsWith('..') || isAbsolute(r)) {
|
|
61
|
+
throw new ConfigError(`Path escapes the project directory: ${rel}`);
|
|
62
|
+
}
|
|
63
|
+
return abs;
|
|
64
|
+
}
|
|
39
65
|
export function chunk(items, size) {
|
|
40
66
|
const out = [];
|
|
41
67
|
for (let i = 0; i < items.length; i += size)
|
|
@@ -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 {
|