@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/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
|
-
|
|
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:
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* at an attacker host would
|
|
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://
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
const rawTargets = raw['targetLocales'];
|
|
196
|
+
if (rawTargets !== undefined && rawTargets !== null && !Array.isArray(rawTargets)) {
|
|
129
197
|
issues.push({
|
|
130
198
|
path: 'targetLocales',
|
|
131
|
-
message: '
|
|
199
|
+
message: 'must be an array of locale codes when set',
|
|
132
200
|
});
|
|
133
201
|
}
|
|
134
|
-
else {
|
|
135
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* at an attacker host would
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
package/dist/history.js
ADDED
|
@@ -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
|