@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/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/*`; commands not yet implemented print a clear "not implemented
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: options.project,
87
- server: options.server,
88
- sourceLocale: options.source,
89
- targetLocales: options.targets?.split(',').map((s) => s.trim()),
206
+ project,
207
+ server,
208
+ sourceLocale: source,
209
+ targetLocales: targets,
90
210
  force: options.force,
91
211
  });
92
- console.log(`✓ wrote ${res.configPath}`);
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
- console.log(' edit the project id + locales, then run `lionrapid push`.');
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', true, res);
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
- out(` · ${f.target}: ${f.count}${res.dryRun ? ' (not written)' : ''}`);
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', true, { ...res, types });
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, { cwd: ctx.cwd });
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.ok) {
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(`✖ ${res.issues.length} issue(s):`);
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;
@@ -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 { findConfigPath, loadConfig, resolveApiKey, ConfigError, } from '../config.js';
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
- return { config: loadConfig(path), cwd };
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 (ping) → declared source files present.
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 {
@@ -5,11 +5,12 @@
5
5
  * turned into a failed check.
6
6
  *
7
7
  * Checks: config found + valid → API key resolvable → server reachable + the
8
- * token works (ping) → declared source files present.
8
+ * token works (config:read probe — pk_-compatible) → declared source files
9
+ * present.
9
10
  */
10
11
  import { existsSync } from 'node:fs';
11
12
  import { resolve } from 'node:path';
12
- import { findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
13
+ import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
13
14
  import { LionRapidApiClient } from '../api-client.js';
14
15
  function msg(err) {
15
16
  return err instanceof Error ? err.message.split('\n')[0] : String(err);
@@ -29,6 +30,7 @@ export async function runDoctor(opts) {
29
30
  }
30
31
  else {
31
32
  config = loadConfig(path);
33
+ config = { ...config, files: expandGlobEntries(config.files, opts.cwd) };
32
34
  checks.push({
33
35
  name: 'config',
34
36
  ok: true,
@@ -57,11 +59,13 @@ export async function runDoctor(opts) {
57
59
  retries: 0,
58
60
  fetchImpl: opts.fetchImpl,
59
61
  });
60
- const res = await client.ping();
62
+ // config:read probe — works for every key class incl. publishable pk_
63
+ // (editor/workflow needs targets:write and would fail pk_ keys).
64
+ const pc = await client.getProjectConfig();
61
65
  checks.push({
62
66
  name: 'server',
63
67
  ok: true,
64
- detail: `${config.server} reachable · ${res.isPmAdmin ? 'pm-admin' : 'integration'} token`,
68
+ detail: `${config.server} reachable · project locales: ${pc.defaultLocale || '?'} → ${pc.languages.filter((l) => l !== pc.defaultLocale).join(', ') || '?'}`,
65
69
  });
66
70
  }
67
71
  catch (err) {
@@ -8,10 +8,42 @@
8
8
  * action only supplies overrides and prints next steps.
9
9
  */
10
10
  import type { FileFormat } from '../config.js';
11
+ import type { LionRapidApiClient } from '../api-client.js';
12
+ /**
13
+ * Harvest the target locales a repo *already has* by matching the detected
14
+ * `target` patterns against real files — so `init` can scaffold what's on
15
+ * disk instead of a hardcoded guess when the server has nothing to say.
16
+ * Multi-locale catalogs (no `{locale}` in the path) can't be detected this
17
+ * way and are skipped. Returns sorted, deduped locales minus `sourceLocale`.
18
+ */
19
+ export declare function detectLocalesOnDisk(cwd: string, sourceLocale: string): string[];
20
+ /**
21
+ * What the project-config lookup produced. `no-targets` is deliberately
22
+ * distinct from `unavailable`: a brand-new project answers perfectly well and
23
+ * simply has no target languages yet — reporting that as "couldn't read your
24
+ * locales" hides the one thing the user has to go do.
25
+ */
26
+ export type LocaleDefaults = {
27
+ status: 'ok';
28
+ sourceLocale: string;
29
+ targetLocales: string[];
30
+ } | {
31
+ status: 'no-targets';
32
+ sourceLocale: string;
33
+ } | {
34
+ status: 'unavailable';
35
+ };
36
+ /**
37
+ * Fetch the project's configured locales for scaffold prefill. Never throws —
38
+ * offline, an older server, or a bad key all degrade to `unavailable`, so
39
+ * `init` keeps working fully offline.
40
+ */
41
+ export declare function fetchLocaleDefaults(client: Pick<LionRapidApiClient, 'getProjectConfig'>): Promise<LocaleDefaults>;
11
42
  interface DetectedFile {
12
43
  format: FileFormat;
13
44
  source: string;
14
45
  target: string;
46
+ namespace?: string;
15
47
  }
16
48
  export interface DetectResult {
17
49
  stacks: string[];
@@ -26,10 +58,22 @@ export interface InitAnswers {
26
58
  targetLocales?: string[];
27
59
  force?: boolean;
28
60
  }
61
+ /** One field `init` changed, or wanted to change but didn't. */
62
+ export interface InitChange {
63
+ path: string;
64
+ from?: string;
65
+ to: string;
66
+ }
29
67
  export interface InitResult {
30
68
  configPath: string;
31
69
  yaml: string;
32
70
  detected: DetectResult;
71
+ /** True when there was no config before this run. */
72
+ created: boolean;
73
+ /** Changes written (new fields/entries — nothing was overwritten). */
74
+ applied: InitChange[];
75
+ /** Differences left alone: your value stands until you edit or use --force. */
76
+ conflicts: InitChange[];
33
77
  }
34
78
  export declare function runInit(cwd: string, answers?: InitAnswers): InitResult;
35
79
  export {};
Binary file
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `login` / `logout` — manage `~/.lionrapid/credentials`, the JSON map
3
+ * (`{ "default": "sk_…", "<server-url>": "sk_…" }`) that `resolveApiKey`
4
+ * reads. The key is verified against the server BEFORE persisting (a
5
+ * config:read probe, so publishable pk_ keys work too) — a failed
6
+ * verification writes nothing. Directory is 0700, file 0600.
7
+ *
8
+ * `--server` writes a server-scoped entry (the per-host opt-in that
9
+ * `resolveApiKey` demands for non-LionRapid hosts); bare `login` writes
10
+ * `default`. The key itself is never echoed or included in any envelope.
11
+ */
12
+ import { type FetchImpl } from '../api-client.js';
13
+ export declare const DEFAULT_SERVER = "https://api.lionrapid.com";
14
+ export interface LoginOptions {
15
+ /** Save the key scoped to this server URL (omitted: save as `default`). */
16
+ server?: string;
17
+ /** The API key (omitted: hidden interactive prompt). */
18
+ key?: string;
19
+ /** Credentials file path (defaults to ~/.lionrapid/credentials). */
20
+ credentialsPath?: string;
21
+ /** Directory to look for a repo lionrapid.yml whose server becomes the ping target. */
22
+ cwd?: string;
23
+ /** Injectable fetch for the verification ping. */
24
+ fetchImpl?: FetchImpl;
25
+ /** Injectable prompt (defaults to a muted readline prompt on stderr). */
26
+ promptKey?: () => Promise<string>;
27
+ }
28
+ export interface LoginResult {
29
+ server: string;
30
+ scope: 'default' | 'server';
31
+ credentialsPath: string;
32
+ }
33
+ export interface LogoutOptions {
34
+ server?: string;
35
+ credentialsPath?: string;
36
+ }
37
+ export interface LogoutResult {
38
+ scope: 'default' | 'server';
39
+ /** True when an entry was actually deleted. */
40
+ removed: boolean;
41
+ credentialsPath: string;
42
+ }
43
+ export declare function runLogin(opts?: LoginOptions): Promise<LoginResult>;
44
+ export declare function runLogout(opts?: LogoutOptions): LogoutResult;
45
+ /** Hidden interactive prompt (no echo) via node:readline — no new dependency. */
46
+ export declare function promptHidden(question?: string): Promise<string>;
47
+ //# sourceMappingURL=login.d.ts.map