@lionrapid/cli 0.2.1 → 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/dist/config.js CHANGED
@@ -9,10 +9,11 @@
9
9
  * - `parseConfig(string)` is split from `loadConfig(path)` so the validator is
10
10
  * unit-testable without touching the filesystem.
11
11
  */
12
- import { existsSync, readFileSync } from 'node:fs';
12
+ import { existsSync, readFileSync, statSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
- import { join } from 'node:path';
14
+ import { basename, join } from 'node:path';
15
15
  import { parse as parseYaml } from 'yaml';
16
+ import { discoverFiles } from './scan/discover.js';
16
17
  /** Translation file formats the CLI can parse/serialize. */
17
18
  export const FILE_FORMATS = [
18
19
  'json',
@@ -29,6 +30,10 @@ export const FILE_FORMATS = [
29
30
  * `target` path is one shared file and does not need a `{locale}` placeholder.
30
31
  */
31
32
  export const MULTI_LOCALE_FORMATS = new Set(['xcstrings']);
33
+ /** True when a `files[].source` is a glob pattern rather than a concrete path. */
34
+ export function isGlobSource(source) {
35
+ return /[*?]/.test(source);
36
+ }
32
37
  /** Thrown when `lionrapid.yml` is missing, unparseable, or invalid. */
33
38
  export class ConfigError extends Error {
34
39
  issues;
@@ -227,16 +232,79 @@ function validateConfig(raw) {
227
232
  entryNs = entry['namespace'];
228
233
  }
229
234
  }
235
+ // flat (optional): only JSON/YAML re-nest dotted keys on serialize, so
236
+ // the flag is meaningless (and likely a config mistake) anywhere else.
237
+ let entryFlat;
238
+ if (entry['flat'] !== undefined) {
239
+ if (typeof entry['flat'] !== 'boolean') {
240
+ issues.push({ path: `${base}.flat`, message: 'must be a boolean when set' });
241
+ }
242
+ else if (validFormat && format !== 'json' && format !== 'yaml') {
243
+ issues.push({
244
+ path: `${base}.flat`,
245
+ message: `only meaningful for json/yaml (${String(format)} output is always flat)`,
246
+ });
247
+ }
248
+ else {
249
+ entryFlat = entry['flat'];
250
+ }
251
+ }
252
+ // Glob sources: {basename} must appear in target AND namespace (else the
253
+ // expansion is ambiguous — every match would collide); a concrete source
254
+ // must not use the token at all.
255
+ const src = entry['source'];
256
+ const tgt = entry['target'];
257
+ const glob = isNonEmptyString(src) && isGlobSource(src);
258
+ const srcHasBase = isNonEmptyString(src) && src.includes('{basename}');
259
+ const tgtHasBase = isNonEmptyString(tgt) && tgt.includes('{basename}');
260
+ const nsHasBase = entryNs?.includes('{basename}') ?? false;
261
+ if (srcHasBase) {
262
+ issues.push({
263
+ path: `${base}.source`,
264
+ message: 'the "{basename}" token is only valid in target/namespace',
265
+ });
266
+ }
267
+ if (glob) {
268
+ if (isNonEmptyString(tgt) && !tgtHasBase) {
269
+ issues.push({
270
+ path: `${base}.target`,
271
+ message: 'must contain the "{basename}" token when source is a glob',
272
+ });
273
+ }
274
+ if (!nsHasBase) {
275
+ issues.push({
276
+ path: `${base}.namespace`,
277
+ message: 'required for a glob source and must contain the "{basename}" token',
278
+ });
279
+ }
280
+ }
281
+ else {
282
+ if (tgtHasBase) {
283
+ issues.push({
284
+ path: `${base}.target`,
285
+ message: 'the "{basename}" token is only valid when source is a glob',
286
+ });
287
+ }
288
+ if (nsHasBase) {
289
+ issues.push({
290
+ path: `${base}.namespace`,
291
+ message: 'the "{basename}" token is only valid when source is a glob',
292
+ });
293
+ }
294
+ }
230
295
  // Only push a typed entry if its required fields are sound.
231
296
  if (validFormat &&
232
297
  isNonEmptyString(entry['source']) &&
233
298
  isNonEmptyString(entry['target']) &&
234
- (isMultiLocale || entry['target'].includes('{locale}'))) {
299
+ (isMultiLocale || entry['target'].includes('{locale}')) &&
300
+ !srcHasBase &&
301
+ (glob ? tgtHasBase && nsHasBase : !tgtHasBase && !nsHasBase)) {
235
302
  files.push({
236
303
  format: format,
237
304
  source: entry['source'],
238
305
  target: entry['target'],
239
306
  ...(entryNs ? { namespace: entryNs } : {}),
307
+ ...(entryFlat !== undefined ? { flat: entryFlat } : {}),
240
308
  });
241
309
  }
242
310
  });
@@ -270,6 +338,41 @@ export function parseConfig(rawYaml) {
270
338
  }
271
339
  return validateConfig(parsed);
272
340
  }
341
+ /**
342
+ * Expand glob `files` entries into one concrete entry per matched file: the
343
+ * match's extension-less basename substitutes the `{basename}` token in
344
+ * `target` and `namespace`. Non-glob entries pass through untouched; matches
345
+ * come back sorted (deterministic expansion). A glob matching nothing warns
346
+ * instead of silently vanishing — a typo'd pattern must not no-op a push.
347
+ *
348
+ * Called at config load (loadOfflineContext / doctor), so every command
349
+ * downstream only ever sees concrete entries.
350
+ */
351
+ export function expandGlobEntries(files, cwd, warn = (m) => console.error(m)) {
352
+ const out = [];
353
+ for (const entry of files) {
354
+ if (!isGlobSource(entry.source)) {
355
+ out.push(entry);
356
+ continue;
357
+ }
358
+ const matches = discoverFiles(cwd, { include: [entry.source] });
359
+ if (matches.length === 0) {
360
+ warn(`warning: files entry "${entry.source}" matched no files`);
361
+ continue;
362
+ }
363
+ for (const rel of matches) {
364
+ const base = basename(rel).replace(/\.[^.]*$/, '');
365
+ out.push({
366
+ format: entry.format,
367
+ source: rel,
368
+ target: entry.target.replaceAll('{basename}', base),
369
+ namespace: (entry.namespace ?? '{basename}').replaceAll('{basename}', base),
370
+ ...(entry.flat !== undefined ? { flat: entry.flat } : {}),
371
+ });
372
+ }
373
+ }
374
+ return out;
375
+ }
273
376
  /** Read + parse + validate the config at `path`. */
274
377
  export function loadConfig(path) {
275
378
  if (!existsSync(path)) {
@@ -277,6 +380,55 @@ export function loadConfig(path) {
277
380
  }
278
381
  return parseConfig(readFileSync(path, 'utf8'));
279
382
  }
383
+ /** Where the CLI reads/writes credentials: `~/.lionrapid/credentials`. */
384
+ export function defaultCredentialsPath() {
385
+ return join(homedir(), '.lionrapid', 'credentials');
386
+ }
387
+ /** Paths already warned about this process — the perms warning fires once per file. */
388
+ const warnedPerms = new Set();
389
+ /** Warn (once per path) when the credentials file is readable by group/other. */
390
+ function warnIfWorldReadable(path) {
391
+ if (process.platform === 'win32' || warnedPerms.has(path))
392
+ return;
393
+ if ((statSync(path).mode & 0o077) !== 0) {
394
+ warnedPerms.add(path);
395
+ console.error(`warning: ${path} is readable by other users — run: chmod 600 ${path}`);
396
+ }
397
+ }
398
+ /**
399
+ * Reject a key that can't travel in an HTTP header (fetch encodes headers as
400
+ * Latin-1 and throws an obscure ByteString error otherwise). The classic case
401
+ * is copying the dashboard's truncated key display, `sk_eng_…last4` — that
402
+ * ellipsis is U+2026, not part of the key.
403
+ */
404
+ export function assertKeyCharset(key) {
405
+ const bad = /[^\x21-\x7e]/.exec(key);
406
+ if (bad) {
407
+ const c = bad[0];
408
+ const cp = c.codePointAt(0).toString(16).padStart(4, '0').toUpperCase();
409
+ throw new ApiKeyError(`The API key contains a character that can't be sent in an HTTP header: '${c}' (U+${cp}). ` +
410
+ 'This usually means a truncated display value was copied — paste the full key.');
411
+ }
412
+ return key;
413
+ }
414
+ /**
415
+ * Server URLs that have a scoped entry in the credentials file. Lets `init`
416
+ * default its server to the one you just logged into instead of asking again.
417
+ */
418
+ export function listCredentialServers(credentialsPath) {
419
+ const path = credentialsPath ?? defaultCredentialsPath();
420
+ if (!existsSync(path))
421
+ return [];
422
+ try {
423
+ const creds = JSON.parse(readFileSync(path, 'utf8'));
424
+ if (!isRecord(creds))
425
+ return [];
426
+ return Object.keys(creds).filter((k) => k !== 'default' && isNonEmptyString(creds[k]));
427
+ }
428
+ catch {
429
+ return [];
430
+ }
431
+ }
280
432
  /**
281
433
  * Resolve the API key. Precedence:
282
434
  * 1. `LIONRAPID_API_KEY` environment variable
@@ -292,9 +444,10 @@ export function resolveApiKey(opts = {}) {
292
444
  const env = opts.env ?? process.env;
293
445
  const fromEnv = env['LIONRAPID_API_KEY'];
294
446
  if (isNonEmptyString(fromEnv))
295
- return fromEnv.trim();
296
- const credentialsPath = opts.credentialsPath ?? join(homedir(), '.lionrapid', 'credentials');
447
+ return assertKeyCharset(fromEnv.trim());
448
+ const credentialsPath = opts.credentialsPath ?? defaultCredentialsPath();
297
449
  if (existsSync(credentialsPath)) {
450
+ warnIfWorldReadable(credentialsPath);
298
451
  let creds;
299
452
  try {
300
453
  creds = JSON.parse(readFileSync(credentialsPath, 'utf8'));
@@ -305,11 +458,12 @@ export function resolveApiKey(opts = {}) {
305
458
  if (isRecord(creds)) {
306
459
  const scoped = opts.server ? creds[opts.server] : undefined;
307
460
  if (isNonEmptyString(scoped))
308
- return scoped.trim();
461
+ return assertKeyCharset(scoped.trim());
309
462
  const fallback = creds['default'];
310
463
  if (isNonEmptyString(fallback)) {
311
- if (!opts.server || isTrustedDefaultHost(opts.server))
312
- return fallback.trim();
464
+ if (!opts.server || isTrustedDefaultHost(opts.server)) {
465
+ return assertKeyCharset(fallback.trim());
466
+ }
313
467
  throw new ApiKeyError(`Refusing to send the "default" credential to unrecognized host ${opts.server}. ` +
314
468
  `Add a server-scoped key for it first: lionrapid login --server ${opts.server}`);
315
469
  }
@@ -52,6 +52,12 @@ function readBlock(text, open) {
52
52
  }
53
53
  return { body: text.slice(open + 1), end: text.length };
54
54
  }
55
+ /**
56
+ * Only identifier-shaped labels may reach type position — with `types --server`
57
+ * the message text is server-controlled, and anything looser could break out of
58
+ * the `'label'` literal in the generated .d.ts.
59
+ */
60
+ const SAFE_LABEL_RE = /^[A-Za-z0-9_]+$/;
55
61
  /** Extract top-level select option labels (excluding `other`). */
56
62
  function selectOptions(body) {
57
63
  // body looks like: `gender, select, male{…} female{…} other{…}` — strip the
@@ -69,7 +75,7 @@ function selectOptions(body) {
69
75
  while (i < rest.length && /\s/.test(rest[i]))
70
76
  i += 1;
71
77
  if (label && rest[i] === '{') {
72
- if (label !== 'other')
78
+ if (label !== 'other' && SAFE_LABEL_RE.test(label))
73
79
  options.push(label);
74
80
  let depth = 1;
75
81
  i += 1;
@@ -5,7 +5,15 @@
5
5
  */
6
6
  import { extractParams } from './extract.js';
7
7
  function escapeKey(key) {
8
- return key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
8
+ return (key
9
+ .replace(/\\/g, '\\\\')
10
+ .replace(/'/g, "\\'")
11
+ // Control chars and JS line terminators (incl. U+2028/29) would break out
12
+ // of the quoted key — keys are server-controlled under `types --server`.
13
+ // eslint-disable-next-line no-control-regex
14
+ .replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, (c) => {
15
+ return `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`;
16
+ }));
9
17
  }
10
18
  export function generateDts(keyTexts, options = {}) {
11
19
  const keys = [...keyTexts.keys()].sort();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lionrapid/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "LionRapid CLI — sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",