@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/dist/config.d.ts CHANGED
@@ -41,7 +41,22 @@ export interface FileEntry {
41
41
  /** True when a `files[].source` is a glob pattern rather than a concrete path. */
42
42
  export declare function isGlobSource(source: string): boolean;
43
43
  export interface LionRapidConfig {
44
- project: string;
44
+ /**
45
+ * The project the API key must resolve to — checked on every push.
46
+ *
47
+ * Replaces the old free-text `project:`, which nothing verified. Proven on
48
+ * 2026-08-07: a push declaring `project: cli-verify`, a name that does not
49
+ * exist, landed in the project the KEY belongs to, while the CLI reported
50
+ * the config's label for 30 jobs that went somewhere else. THE API KEY
51
+ * SELECTS THE PROJECT; the config never could.
52
+ *
53
+ * OPTIONAL, because the server field it comes from (`meta.project_uuid`) is
54
+ * real but UNDECLARED — the translations route returns a raw Response to set
55
+ * its ETag header, bypassing the response model, so a later tidy-up could
56
+ * remove it in what looks like a cosmetic change (server task #70). A guard
57
+ * that cannot arm must not block a push.
58
+ */
59
+ projectId?: string;
45
60
  server: string;
46
61
  sourceLocale: string;
47
62
  targetLocales: string[];
@@ -71,8 +86,26 @@ export declare class ConfigError extends Error {
71
86
  export declare class ApiKeyError extends Error {
72
87
  constructor(message: string);
73
88
  }
89
+ /** Opt-in for sending a key to a host we do not recognize (self-hosted). */
90
+ export declare const ALLOW_UNTRUSTED_HOST_ENV = "LIONRAPID_ALLOW_UNTRUSTED_HOST";
74
91
  /** Locate the config file in `cwd`, returning its path or null. */
75
92
  export declare function findConfigPath(cwd?: string): string | null;
93
+ /**
94
+ * What a source-only config costs you. Named commands, not a vague "no
95
+ * targets": the point is that nothing is broken — two commands simply have
96
+ * nothing to do until the project has target languages.
97
+ */
98
+ export declare const NO_TARGETS_WARNING: string;
99
+ /**
100
+ * The warning, but only for the commands it applies to.
101
+ *
102
+ * Emitting it at parse time meant every command carried it — including
103
+ * `status`, which reports what the backend did with a job and has nothing to
104
+ * do with target locales. A warning that fires where it does not apply is how
105
+ * people learn to ignore warnings, so the three commands that are actually
106
+ * affected ask for it and nobody else sees it.
107
+ */
108
+ export declare function noTargetsNotice(config: LionRapidConfig): string | undefined;
76
109
  /** Parse + validate a YAML config string into a typed config. */
77
110
  export declare function parseConfig(rawYaml: string): LionRapidConfig;
78
111
  /**
@@ -88,6 +121,19 @@ export declare function parseConfig(rawYaml: string): LionRapidConfig;
88
121
  export declare function expandGlobEntries(files: FileEntry[], cwd: string, warn?: (message: string) => void): FileEntry[];
89
122
  /** Read + parse + validate the config at `path`. */
90
123
  export declare function loadConfig(path: string): LionRapidConfig;
124
+ /**
125
+ * The user's home directory, `$HOME` first.
126
+ *
127
+ * Identical to `os.homedir()` for real users on POSIX (it reads `$HOME` too),
128
+ * but a JS-level `process.env.HOME` assignment is invisible to `homedir()`'s
129
+ * native `getenv` inside a worker thread — which is where vitest runs. Without
130
+ * this, the suite reads (and could write) the developer's real
131
+ * `~/.lionrapid/`: a test asserting "no credentials" passes in CI and fails on
132
+ * a laptop that has logged in, which is a silent source of divergence.
133
+ *
134
+ * Shared by {@link defaultCredentialsPath} and the job history.
135
+ */
136
+ export declare function homeDir(): string;
91
137
  /** Where the CLI reads/writes credentials: `~/.lionrapid/credentials`. */
92
138
  export declare function defaultCredentialsPath(): string;
93
139
  /**
@@ -116,10 +162,59 @@ export interface ResolveApiKeyOptions {
116
162
  * 2. credentials file (JSON: `{ "default": "sk_...", "<server>": "sk_..." }`)
117
163
  * The key is never read from `lionrapid.yml` (which is committed to git).
118
164
  *
119
- * Trust rule: the `default` credential is only handed out for recognized
120
- * LionRapid hosts (`*.lionrapid.com`) and localhost. Any other server needs
121
- * its own server-scoped entry — otherwise a committed lionrapid.yml pointing
122
- * at an attacker host would exfiltrate the key of anyone running `push`.
165
+ * Trust rule: a key is only handed out for recognized LionRapid hosts
166
+ * (`*.lionrapid.com`) and localhost. Any other server needs either its own
167
+ * server-scoped credentials entry or {@link ALLOW_UNTRUSTED_HOST_ENV} —
168
+ * otherwise a committed lionrapid.yml pointing at an attacker host would
169
+ * exfiltrate the key of anyone running `push`.
123
170
  */
124
171
  export declare function resolveApiKey(opts?: ResolveApiKeyOptions): string;
172
+ /** Where a resolved key came from. `whoami` shows this; nothing else needs it. */
173
+ export type KeySource = 'env' | 'server-scoped' | 'default';
174
+ export interface ResolvedKey {
175
+ key: string;
176
+ source: KeySource;
177
+ /** The env var name, or the credentials entry the key was read from. */
178
+ scope: string;
179
+ }
180
+ /**
181
+ * {@link resolveApiKey}, plus WHERE the key came from.
182
+ *
183
+ * One implementation, two views: `whoami` has to say which credential is about
184
+ * to be used, and a second copy of this precedence ladder would be a place for
185
+ * the two to drift — the displayed key would stop being the sent key, which is
186
+ * the exact confusion `whoami` exists to end.
187
+ */
188
+ export declare function resolveApiKeyDetailed(opts?: ResolveApiKeyOptions): ResolvedKey;
189
+ /**
190
+ * Display form of a key: prefix + last four, never the middle.
191
+ *
192
+ * A key shorter than the prefix+last4 window would otherwise be echoed almost
193
+ * whole, so a short (malformed, truncated) value drops the prefix entirely —
194
+ * "mask" must not become "print" for the one input where it matters.
195
+ */
196
+ export declare function maskKey(key: string): string;
197
+ /** What a key class can and cannot do, in the terms a user actually hits. */
198
+ export interface KeyCapability {
199
+ kind: 'publishable' | 'master' | 'editor-session' | 'unknown';
200
+ /** One line, naming the functional line rather than the scope vocabulary. */
201
+ summary: string;
202
+ /** True when `push --target` / `--publish` will work (needs `targets:write`). */
203
+ canWriteTargets: boolean;
204
+ }
205
+ /**
206
+ * A key's class, from its prefix.
207
+ *
208
+ * The functional line is `targets:write`, NOT the queue scopes. A `pk_` holder
209
+ * can push sources, pull and read status, but `push --target` dies on a bare
210
+ * 403 from `targets/batch` — hit for real on 2026-08-07 while trying to
211
+ * publish a row, and exactly the kind of thing worth saying BEFORE the request
212
+ * instead of after it.
213
+ */
214
+ export declare function keyCapability(key: string): KeyCapability;
215
+ /** Every saved credential, as `{ scope, masked }`. Never returns key material. */
216
+ export declare function listCredentials(credentialsPath?: string): {
217
+ scope: string;
218
+ masked: string;
219
+ }[];
125
220
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -72,6 +72,42 @@ function isTrustedDefaultHost(server) {
72
72
  const host = url.hostname;
73
73
  return host === 'lionrapid.com' || host.endsWith('.lionrapid.com') || LOCAL_HOSTS.has(host);
74
74
  }
75
+ /** Opt-in for sending a key to a host we do not recognize (self-hosted). */
76
+ export const ALLOW_UNTRUSTED_HOST_ENV = 'LIONRAPID_ALLOW_UNTRUSTED_HOST';
77
+ /**
78
+ * True only for an explicit `1`/`true`. A flag someone set to `0` or `false`
79
+ * to turn the behaviour OFF must never turn it on — "any non-empty value"
80
+ * would do exactly that.
81
+ */
82
+ function allowsUntrustedHost(env) {
83
+ const raw = env[ALLOW_UNTRUSTED_HOST_ENV]?.trim().toLowerCase();
84
+ return raw === '1' || raw === 'true';
85
+ }
86
+ /**
87
+ * Hold `LIONRAPID_API_KEY` to the same trust boundary as the credentials-file
88
+ * `default`.
89
+ *
90
+ * The env path used to return before any host check, on the reasoning that an
91
+ * operator who exports a secret has vetted where it goes (PLAN 19, 34ae81a:
92
+ * "CI sets it deliberately"). That reasoning fails in the very place it was
93
+ * written for. In CI the two halves are set by DIFFERENT PEOPLE: the operator
94
+ * injects the secret once, while `server:` lives in a committed lionrapid.yml
95
+ * that can arrive by pull request. Setting a key deliberately is not the same
96
+ * as approving its destination, and a valid key sent to an attacker is not
97
+ * self-correcting the way a wrong key is (that just 401s).
98
+ *
99
+ * No server yet (an invalid config, so `doctor` has nothing to match on) means
100
+ * there is no host to distrust — allow it, and let the config check report the
101
+ * real problem.
102
+ */
103
+ function assertEnvKeyDestination(server, env) {
104
+ if (!server || isTrustedDefaultHost(server) || allowsUntrustedHost(env))
105
+ return;
106
+ throw new ApiKeyError(`Refusing to send LIONRAPID_API_KEY to ${server} — not a recognized LionRapid host.\n` +
107
+ ' `server:` comes from lionrapid.yml, which can change by pull request, so an\n' +
108
+ ' unrecognized host means your key travels wherever that file says.\n' +
109
+ ` If this is your own self-hosted server, set ${ALLOW_UNTRUSTED_HOST_ENV}=1.`);
110
+ }
75
111
  /** Validate the `server` config value; returns an issue message or null if OK. */
76
112
  function validateServerUrl(server) {
77
113
  let url;
@@ -79,7 +115,7 @@ function validateServerUrl(server) {
79
115
  url = new URL(server);
80
116
  }
81
117
  catch {
82
- return 'must be a valid https:// URL (e.g. https://api.lionrapid.com)';
118
+ return 'must be a valid https:// URL (e.g. https://app.lionrapid.com)';
83
119
  }
84
120
  if (url.protocol === 'https:')
85
121
  return null;
@@ -102,16 +138,35 @@ function isRecord(value) {
102
138
  function isNonEmptyString(value) {
103
139
  return typeof value === 'string' && value.trim().length > 0;
104
140
  }
141
+ /**
142
+ * What a source-only config costs you. Named commands, not a vague "no
143
+ * targets": the point is that nothing is broken — two commands simply have
144
+ * nothing to do until the project has target languages.
145
+ */
146
+ export const NO_TARGETS_WARNING = 'note: no targetLocales in lionrapid.yml — `push` still uploads your source strings,\n' +
147
+ ' but `pull` and `push --target` have nothing to do until this project has target\n' +
148
+ ' languages. Add them in the dashboard, then re-run `lionrapid init`.';
149
+ /**
150
+ * The warning, but only for the commands it applies to.
151
+ *
152
+ * Emitting it at parse time meant every command carried it — including
153
+ * `status`, which reports what the backend did with a job and has nothing to
154
+ * do with target locales. A warning that fires where it does not apply is how
155
+ * people learn to ignore warnings, so the three commands that are actually
156
+ * affected ask for it and nobody else sees it.
157
+ */
158
+ export function noTargetsNotice(config) {
159
+ return config.targetLocales.length === 0 ? NO_TARGETS_WARNING : undefined;
160
+ }
105
161
  /** Validate a raw parsed object into a typed config, collecting all issues. */
106
162
  function validateConfig(raw) {
107
163
  const issues = [];
108
164
  if (!isRecord(raw)) {
109
165
  throw new ConfigError('Config must be a YAML mapping (key: value pairs).');
110
166
  }
111
- // Scalars
112
- if (!isNonEmptyString(raw['project'])) {
113
- issues.push({ path: 'project', message: 'required non-empty string' });
114
- }
167
+ // Scalars. NOTE: `project` is deliberately unread — the old free-text
168
+ // label is gone, and a config that still carries one simply ignores it
169
+ // rather than failing (pre-customer, but a stale field is not an error).
115
170
  if (!isNonEmptyString(raw['server'])) {
116
171
  issues.push({ path: 'server', message: 'required non-empty string' });
117
172
  }
@@ -123,16 +178,29 @@ function validateConfig(raw) {
123
178
  if (!isNonEmptyString(raw['sourceLocale'])) {
124
179
  issues.push({ path: 'sourceLocale', message: 'required non-empty string' });
125
180
  }
126
- // targetLocales
181
+ if (raw['projectId'] !== undefined && !isNonEmptyString(raw['projectId'])) {
182
+ issues.push({ path: 'projectId', message: 'must be a non-empty string when set' });
183
+ }
184
+ // targetLocales — OPTIONAL. `push` sends source rows and the server decides
185
+ // what to translate them into, so a config that only declares a source is a
186
+ // complete, working config. Requiring targets made `init`'s own output
187
+ // invalid (it writes `targetLocales: []` whenever it cannot reach the
188
+ // server), and an invalid config makes `doctor` blame the API key instead.
189
+ //
190
+ // Absent and empty both warn; a wrong TYPE is still an error, because that
191
+ // is a typo rather than a choice.
192
+ // An absent or empty list is simply an empty list — `noTargetsNotice` turns
193
+ // that into a message for the commands it concerns.
127
194
  const targetLocales = [];
128
- if (!Array.isArray(raw['targetLocales']) || raw['targetLocales'].length === 0) {
195
+ const rawTargets = raw['targetLocales'];
196
+ if (rawTargets !== undefined && rawTargets !== null && !Array.isArray(rawTargets)) {
129
197
  issues.push({
130
198
  path: 'targetLocales',
131
- message: 'required non-empty array of locale codes',
199
+ message: 'must be an array of locale codes when set',
132
200
  });
133
201
  }
134
- else {
135
- raw['targetLocales'].forEach((loc, i) => {
202
+ else if (Array.isArray(rawTargets) && rawTargets.length > 0) {
203
+ rawTargets.forEach((loc, i) => {
136
204
  if (!isNonEmptyString(loc)) {
137
205
  issues.push({ path: `targetLocales[${i}]`, message: 'must be a non-empty string' });
138
206
  }
@@ -313,7 +381,7 @@ function validateConfig(raw) {
313
381
  throw new ConfigError('Invalid lionrapid config:', issues);
314
382
  }
315
383
  return {
316
- project: raw['project'],
384
+ ...(isNonEmptyString(raw['projectId']) ? { projectId: raw['projectId'] } : {}),
317
385
  server: raw['server'].replace(/\/$/, ''),
318
386
  sourceLocale: raw['sourceLocale'],
319
387
  targetLocales,
@@ -380,9 +448,24 @@ export function loadConfig(path) {
380
448
  }
381
449
  return parseConfig(readFileSync(path, 'utf8'));
382
450
  }
451
+ /**
452
+ * The user's home directory, `$HOME` first.
453
+ *
454
+ * Identical to `os.homedir()` for real users on POSIX (it reads `$HOME` too),
455
+ * but a JS-level `process.env.HOME` assignment is invisible to `homedir()`'s
456
+ * native `getenv` inside a worker thread — which is where vitest runs. Without
457
+ * this, the suite reads (and could write) the developer's real
458
+ * `~/.lionrapid/`: a test asserting "no credentials" passes in CI and fails on
459
+ * a laptop that has logged in, which is a silent source of divergence.
460
+ *
461
+ * Shared by {@link defaultCredentialsPath} and the job history.
462
+ */
463
+ export function homeDir() {
464
+ return process.env['HOME'] || process.env['USERPROFILE'] || homedir();
465
+ }
383
466
  /** Where the CLI reads/writes credentials: `~/.lionrapid/credentials`. */
384
467
  export function defaultCredentialsPath() {
385
- return join(homedir(), '.lionrapid', 'credentials');
468
+ return join(homeDir(), '.lionrapid', 'credentials');
386
469
  }
387
470
  /** Paths already warned about this process — the perms warning fires once per file. */
388
471
  const warnedPerms = new Set();
@@ -435,16 +518,34 @@ export function listCredentialServers(credentialsPath) {
435
518
  * 2. credentials file (JSON: `{ "default": "sk_...", "<server>": "sk_..." }`)
436
519
  * The key is never read from `lionrapid.yml` (which is committed to git).
437
520
  *
438
- * Trust rule: the `default` credential is only handed out for recognized
439
- * LionRapid hosts (`*.lionrapid.com`) and localhost. Any other server needs
440
- * its own server-scoped entry — otherwise a committed lionrapid.yml pointing
441
- * at an attacker host would exfiltrate the key of anyone running `push`.
521
+ * Trust rule: a key is only handed out for recognized LionRapid hosts
522
+ * (`*.lionrapid.com`) and localhost. Any other server needs either its own
523
+ * server-scoped credentials entry or {@link ALLOW_UNTRUSTED_HOST_ENV} —
524
+ * otherwise a committed lionrapid.yml pointing at an attacker host would
525
+ * exfiltrate the key of anyone running `push`.
442
526
  */
443
527
  export function resolveApiKey(opts = {}) {
528
+ return resolveApiKeyDetailed(opts).key;
529
+ }
530
+ /**
531
+ * {@link resolveApiKey}, plus WHERE the key came from.
532
+ *
533
+ * One implementation, two views: `whoami` has to say which credential is about
534
+ * to be used, and a second copy of this precedence ladder would be a place for
535
+ * the two to drift — the displayed key would stop being the sent key, which is
536
+ * the exact confusion `whoami` exists to end.
537
+ */
538
+ export function resolveApiKeyDetailed(opts = {}) {
444
539
  const env = opts.env ?? process.env;
445
540
  const fromEnv = env['LIONRAPID_API_KEY'];
446
- if (isNonEmptyString(fromEnv))
447
- return assertKeyCharset(fromEnv.trim());
541
+ if (isNonEmptyString(fromEnv)) {
542
+ assertEnvKeyDestination(opts.server, env);
543
+ return {
544
+ key: assertKeyCharset(fromEnv.trim()),
545
+ source: 'env',
546
+ scope: 'LIONRAPID_API_KEY',
547
+ };
548
+ }
448
549
  const credentialsPath = opts.credentialsPath ?? defaultCredentialsPath();
449
550
  if (existsSync(credentialsPath)) {
450
551
  warnIfWorldReadable(credentialsPath);
@@ -457,18 +558,134 @@ export function resolveApiKey(opts = {}) {
457
558
  }
458
559
  if (isRecord(creds)) {
459
560
  const scoped = opts.server ? creds[opts.server] : undefined;
460
- if (isNonEmptyString(scoped))
461
- return assertKeyCharset(scoped.trim());
561
+ if (isNonEmptyString(scoped)) {
562
+ return {
563
+ key: assertKeyCharset(scoped.trim()),
564
+ source: 'server-scoped',
565
+ scope: opts.server,
566
+ };
567
+ }
462
568
  const fallback = creds['default'];
463
569
  if (isNonEmptyString(fallback)) {
464
570
  if (!opts.server || isTrustedDefaultHost(opts.server)) {
465
- return assertKeyCharset(fallback.trim());
571
+ return { key: assertKeyCharset(fallback.trim()), source: 'default', scope: 'default' };
466
572
  }
467
573
  throw new ApiKeyError(`Refusing to send the "default" credential to unrecognized host ${opts.server}. ` +
468
574
  `Add a server-scoped key for it first: lionrapid login --server ${opts.server}`);
469
575
  }
470
576
  }
471
577
  }
472
- throw new ApiKeyError('No API key found. Set LIONRAPID_API_KEY or add it to ~/.lionrapid/credentials.');
578
+ throw new ApiKeyError(noKeyMessage(opts.server, credentialsPath));
579
+ }
580
+ /**
581
+ * Display form of a key: prefix + last four, never the middle.
582
+ *
583
+ * A key shorter than the prefix+last4 window would otherwise be echoed almost
584
+ * whole, so a short (malformed, truncated) value drops the prefix entirely —
585
+ * "mask" must not become "print" for the one input where it matters.
586
+ */
587
+ export function maskKey(key) {
588
+ return maskKeyImpl(key);
589
+ }
590
+ /**
591
+ * A key's class, from its prefix.
592
+ *
593
+ * The functional line is `targets:write`, NOT the queue scopes. A `pk_` holder
594
+ * can push sources, pull and read status, but `push --target` dies on a bare
595
+ * 403 from `targets/batch` — hit for real on 2026-08-07 while trying to
596
+ * publish a row, and exactly the kind of thing worth saying BEFORE the request
597
+ * instead of after it.
598
+ */
599
+ export function keyCapability(key) {
600
+ if (key.startsWith('pk_')) {
601
+ return {
602
+ kind: 'publishable',
603
+ summary: 'publishable — pushes sources, pulls, reads status. CANNOT write or publish targets.',
604
+ canWriteTargets: false,
605
+ };
606
+ }
607
+ if (key.startsWith('sk_')) {
608
+ return {
609
+ kind: 'master',
610
+ summary: 'master — everything, including `push --target` and `--publish`.',
611
+ canWriteTargets: true,
612
+ };
613
+ }
614
+ if (key.startsWith('es_')) {
615
+ return {
616
+ kind: 'editor-session',
617
+ summary: 'editor session — writes and publishes targets, but expires within minutes.',
618
+ canWriteTargets: true,
619
+ };
620
+ }
621
+ return { kind: 'unknown', summary: 'unrecognized key prefix', canWriteTargets: false };
622
+ }
623
+ function maskKeyImpl(key) {
624
+ const trimmed = key.trim();
625
+ const last4 = trimmed.slice(-4);
626
+ return trimmed.length >= 12 ? `${trimmed.slice(0, 3)}…${last4}` : `…${last4}`;
627
+ }
628
+ /** Every saved credential, as `{ scope, masked }`. Never returns key material. */
629
+ export function listCredentials(credentialsPath = defaultCredentialsPath()) {
630
+ if (!existsSync(credentialsPath))
631
+ return [];
632
+ try {
633
+ const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'));
634
+ if (!isRecord(creds))
635
+ return [];
636
+ return Object.entries(creds)
637
+ .filter(([, value]) => isNonEmptyString(value))
638
+ .map(([scope, value]) => ({ scope, masked: maskKey(value) }));
639
+ }
640
+ catch {
641
+ return [];
642
+ }
643
+ }
644
+ /**
645
+ * The one dishonest message in the system, rewritten.
646
+ *
647
+ * "No API key found. Set LIONRAPID_API_KEY or add it to ~/.lionrapid/
648
+ * credentials." was routinely FALSE: keys are stored per server URL, so a key
649
+ * that is present and correct produces this error the moment `server:` points
650
+ * somewhere else — which is exactly what a dead default host caused. It named
651
+ * a missing credential when the real problem was a mismatched server, and cost
652
+ * an hour of hunting through a credentials file that was fine.
653
+ *
654
+ * So: name the server we wanted, list the scopes that DO exist, and give the
655
+ * command that fixes it. The sibling untrusted-host throw a few lines up
656
+ * already read like this — same file, same author, opposite outcomes.
657
+ */
658
+ function noKeyMessage(server, credentialsPath) {
659
+ const saved = listCredentialServers(credentialsPath);
660
+ const hasDefault = hasDefaultCredential(credentialsPath);
661
+ const scopes = [...(hasDefault ? ['default'] : []), ...saved];
662
+ if (scopes.length === 0) {
663
+ return (`No API key${server ? ` for ${server}` : ''}, and no keys saved yet.\n` +
664
+ ` Run: lionrapid login${server ? ` --server ${server}` : ''}\n` +
665
+ ' (or set LIONRAPID_API_KEY for a one-off / CI run.)');
666
+ }
667
+ const list = scopes.map((s) => ` · ${s}`).join('\n');
668
+ if (!server) {
669
+ // doctor's case: the config did not parse, so there is no server to match
670
+ // against. Do not invent one — say which server is in question is unknown.
671
+ return ('No API key could be resolved, because it is not clear which server to use.\n' +
672
+ `Keys are saved per server. You have:\n${list}\n` +
673
+ ' Fix `server:` in lionrapid.yml, or set LIONRAPID_API_KEY.');
674
+ }
675
+ return (`No API key for ${server}.\n` +
676
+ `Keys are saved per server. You have:\n${list}\n` +
677
+ ` Fix \`server:\` in lionrapid.yml, or: lionrapid login --server ${server}`);
678
+ }
679
+ /** True when the credentials file carries a `default` entry. */
680
+ function hasDefaultCredential(credentialsPath) {
681
+ if (!existsSync(credentialsPath))
682
+ return false;
683
+ try {
684
+ const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'));
685
+ return isRecord(creds) && isNonEmptyString(creds['default']);
686
+ }
687
+ catch {
688
+ return false;
689
+ }
473
690
  }
474
691
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `.lionrapid/history/` — a local copy of whatever a `--force` is about to
3
+ * destroy, in either direction.
4
+ *
5
+ * The server versions target rows at STAGE BOUNDARIES, not per edit: an
6
+ * ordinary edit overwrites `targetrow.target` in place. So until per-edit
7
+ * history lands server-side, the only thing standing between `--force` and a
8
+ * lost translation is this directory. Writing it makes `--force` safe TODAY,
9
+ * symmetrically:
10
+ *
11
+ * pull --force → save the LOCAL value, then take the server's
12
+ * push --force → save the SERVER's value, then overwrite it
13
+ *
14
+ * GITIGNORED, unlike the lock. The lock is a team fact and belongs in review;
15
+ * this is a per-machine safety net that may hold a translator's unfinished
16
+ * work. The CLI writes `.lionrapid/.gitignore` itself so the distinction
17
+ * survives a fresh clone without anyone having to know about it.
18
+ */
19
+ import type { ContentUnit } from '@lionrapid/core';
20
+ /** Where snapshots live, relative to the project root. */
21
+ export declare const HISTORY_DIR: string;
22
+ export interface HistoryEntry {
23
+ key: string;
24
+ value: ContentUnit;
25
+ }
26
+ export interface SaveHistoryOptions {
27
+ /** Which side was about to lose data. */
28
+ direction: 'push' | 'pull';
29
+ namespace: string;
30
+ locale: string;
31
+ /** Why this snapshot exists, in the words the user will read. */
32
+ reason: string;
33
+ entries: HistoryEntry[];
34
+ /** Timestamp, injectable so tests are deterministic. */
35
+ at?: string;
36
+ }
37
+ /**
38
+ * Write a snapshot and return its path, or `undefined` when there was nothing
39
+ * to save. Filenames carry a counter as well as a timestamp: two forces in the
40
+ * same second must not have the second one silently replace the first, which
41
+ * would defeat the point.
42
+ */
43
+ export declare function saveHistory(cwd: string, opts: SaveHistoryOptions): string | undefined;
44
+ //# sourceMappingURL=history.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `.lionrapid/history/` — a local copy of whatever a `--force` is about to
3
+ * destroy, in either direction.
4
+ *
5
+ * The server versions target rows at STAGE BOUNDARIES, not per edit: an
6
+ * ordinary edit overwrites `targetrow.target` in place. So until per-edit
7
+ * history lands server-side, the only thing standing between `--force` and a
8
+ * lost translation is this directory. Writing it makes `--force` safe TODAY,
9
+ * symmetrically:
10
+ *
11
+ * pull --force → save the LOCAL value, then take the server's
12
+ * push --force → save the SERVER's value, then overwrite it
13
+ *
14
+ * GITIGNORED, unlike the lock. The lock is a team fact and belongs in review;
15
+ * this is a per-machine safety net that may hold a translator's unfinished
16
+ * work. The CLI writes `.lionrapid/.gitignore` itself so the distinction
17
+ * survives a fresh clone without anyone having to know about it.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ /** Where snapshots live, relative to the project root. */
22
+ export const HISTORY_DIR = join('.lionrapid', 'history');
23
+ /**
24
+ * Write a snapshot and return its path, or `undefined` when there was nothing
25
+ * to save. Filenames carry a counter as well as a timestamp: two forces in the
26
+ * same second must not have the second one silently replace the first, which
27
+ * would defeat the point.
28
+ */
29
+ export function saveHistory(cwd, opts) {
30
+ if (opts.entries.length === 0)
31
+ return undefined;
32
+ const dir = join(cwd, HISTORY_DIR);
33
+ mkdirSync(dir, { recursive: true });
34
+ ensureGitignore(cwd);
35
+ const savedAt = opts.at ?? new Date().toISOString();
36
+ const stamp = savedAt.replace(/[:.]/g, '-');
37
+ const base = `${stamp}-${opts.direction}-${opts.namespace}-${opts.locale}`;
38
+ let path = join(dir, `${base}.json`);
39
+ for (let n = 2; existsSync(path); n += 1)
40
+ path = join(dir, `${base}.${n}.json`);
41
+ const entries = {};
42
+ for (const entry of opts.entries)
43
+ entries[entry.key] = entry.value;
44
+ writeFileSync(path, `${JSON.stringify({
45
+ savedAt,
46
+ direction: opts.direction,
47
+ namespace: opts.namespace,
48
+ locale: opts.locale,
49
+ reason: opts.reason,
50
+ entries,
51
+ }, null, 2)}\n`);
52
+ return path;
53
+ }
54
+ /** Keep `history/` out of git while leaving `lock.json` committed. */
55
+ function ensureGitignore(cwd) {
56
+ const path = join(cwd, '.lionrapid', '.gitignore');
57
+ const line = 'history/';
58
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
59
+ if (existing.split('\n').includes(line))
60
+ return;
61
+ writeFileSync(path, existing
62
+ ? `${existing.replace(/\n?$/, '\n')}${line}\n`
63
+ : `# Local safety copies written before a --force. Not a team fact.\n${line}\n`);
64
+ }
65
+ //# sourceMappingURL=history.js.map