From 12696a3a6c121c64c36c7c44e414a6f05549474f Mon Sep 17 00:00:00 2001 From: romuald DJETEJE Date: Mon, 19 Oct 2026 23:28:25 +0100 Subject: [PATCH] v0.2.0: help, French, summaries, completion - Help: commands grouped (Deploy / Application / Infrastructure / Account), examples on the first screen and under each command, documentation link; a mistyped command gets a suggestion. - French and English: the system's language, IDEM_LANG or --lang; every message, prompt and help text goes through t() (src/locales/fr.ts). - idem deploy ends with a summary: address, duration, linked variables, next commands. - idem alone, in a folder not deployed yet, says the one command to run. - idem completion bash|zsh|fish, generated from the commands themselves. --- README.md | 12 ++ package-lock.json | 4 +- package.json | 2 +- src/commands/apps.ts | 78 +++---- src/commands/auth.ts | 35 ++-- src/commands/databases.ts | 41 ++-- src/commands/deploy.ts | 128 +++++++----- src/commands/env.ts | 23 +- src/commands/logs.ts | 5 +- src/commands/menu.ts | 44 ++-- src/commands/rollback.ts | 23 +- src/commands/servers.ts | 55 ++--- src/commands/services.ts | 19 +- src/commands/workspaces.ts | 35 ++-- src/completion.ts | 78 +++++++ src/help.ts | 65 ++++++ src/i18n.ts | 43 ++++ src/index.ts | 330 +++++++++++++++++++---------- src/locales/fr.ts | 417 +++++++++++++++++++++++++++++++++++++ src/shared.ts | 42 ++-- src/ui.ts | 3 +- 21 files changed, 1132 insertions(+), 350 deletions(-) create mode 100644 src/completion.ts create mode 100644 src/help.ts create mode 100644 src/i18n.ts create mode 100644 src/locales/fr.ts diff --git a/README.md b/README.md index b6cc8e3..bf3f713 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,18 @@ Build settings when the detection needs help: `--base-dir`, `--install`, `--buil Every command takes `--json` for scripts. +## Help, language, completion + +- `idem --help` — the commands grouped by what they are for, with examples; `idem --help` ends with its own examples. A mistyped command gets a suggestion (`idem deplo` → `idem deploy`). +- **French or English**: the CLI follows your system's language. Force it with `IDEM_LANG=fr` (or `en`), or `--lang fr` on one command. +- **Tab completion**: + +```bash +idem completion bash >> ~/.bashrc +idem completion zsh >> ~/.zshrc +idem completion fish > ~/.config/fish/completions/idem.fish +``` + ## In CI Create a token on iDeploy (*Security › API tokens*) and give it as `IDEM_TOKEN`: diff --git a/package-lock.json b/package-lock.json index f7fe461..de74d15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idem-ai/cli", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idem-ai/cli", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.7.0", diff --git a/package.json b/package.json index 1d1fa0d..09915f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idem-ai/cli", - "version": "0.1.0", + "version": "0.2.0", "description": "Deploy to iDeploy from your terminal \u2014 one command: idem deploy", "license": "Apache-2.0", "type": "module", diff --git a/src/commands/apps.ts b/src/commands/apps.ts index 1536420..d7e8848 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -4,6 +4,7 @@ import { readLink, removeLink } from '../project.js'; import { confirmOrStop, linkFolder, listApps, openBrowser, requireSignedIn, resolveApp, table } from '../shared.js'; import { webUrl } from '../config.js'; import { appUrl, color, fail, printJson, prompts } from '../ui.js'; +import { t } from '../i18n.js'; function dot(status?: string | null): string { const s = (status ?? '').toLowerCase(); @@ -17,14 +18,14 @@ export async function apps(): Promise { const list = await listApps(); if (printJson(list.map((a) => ({ uuid: a.uuid, name: a.name, workspace: a.workspace_name, status: a.status, url: appUrl(a) })))) return; if (list.length === 0) { - prompts.log.info(`No application yet. Run ${color.cyan('idem deploy')} in a project folder.`); + prompts.log.info(t('No application yet. Run {command} in a project folder.', { command: color.cyan('idem deploy') })); return; } const linked = readLink()?.applicationUuid; const width = Math.min(Math.max(...list.map((a) => a.name.length)), 32); const lines = list.map((a) => { const url = appUrl(a); - return `${dot(a.status)} ${a.name.padEnd(width)} ${color.dim((a.workspace_name ?? '').padEnd(18))} ${url ? color.cyan(url) : color.dim('—')}${a.uuid === linked ? color.yellow(' ← this folder') : ''}`; + return `${dot(a.status)} ${a.name.padEnd(width)} ${color.dim((a.workspace_name ?? '').padEnd(18))} ${url ? color.cyan(url) : color.dim('—')}${a.uuid === linked ? color.yellow(` ← ${t('this folder')}`) : ''}`; }); process.stdout.write(lines.join('\n') + '\n'); } @@ -36,27 +37,27 @@ export async function link(opts: { app?: string }): Promise { app = await resolveApp(opts.app); } else { // Not resolveApp: it would answer with the application already linked. - if (!process.stdin.isTTY) fail('Which application?', 'Pass `--app `.'); + if (!process.stdin.isTTY) fail(t('Which application?'), t('Pass `--app `.')); const list = await listApps(); - if (list.length === 0) fail('You have no application yet.', 'Run `idem deploy` here instead: it creates one.'); + if (list.length === 0) fail(t('You have no application yet.'), t('Run `idem deploy` here instead: it creates one.')); const uuid = await prompts.select({ - message: 'Link this folder to', + message: t('Link this folder to'), options: list.map((a) => ({ value: a.uuid, label: a.name, hint: a.workspace_name ?? undefined })), }); - if (prompts.isCancel(uuid)) return void prompts.cancel('Not linked.'); + if (prompts.isCancel(uuid)) return void prompts.cancel(t('Not linked.')); app = list.find((a) => a.uuid === uuid)!; } linkFolder(app); - if (!printJson({ linked: app.uuid, name: app.name })) prompts.log.success(`This folder now deploys to ${color.bold(app.name)}.`); + if (!printJson({ linked: app.uuid, name: app.name })) prompts.log.success(t('This folder now deploys to {name}.', { name: color.bold(app.name) })); } export async function unlink(): Promise { const was = readLink(); if (!removeLink()) { - prompts.log.info('This folder was not linked.'); + prompts.log.info(t('This folder was not linked.')); return; } - prompts.log.success(`Unlinked from ${color.bold(was?.applicationName ?? 'the application')}. The application itself is untouched.`); + prompts.log.success(t('Unlinked from {name}. The application itself is untouched.', { name: color.bold(was?.applicationName ?? '') })); } interface DeploymentRow { @@ -75,36 +76,37 @@ export async function status(opts: { app?: string }): Promise { ]); const last = deployments[0]; if (printJson({ uuid: fresh.uuid, name: fresh.name, status: fresh.status, url: appUrl(fresh), lastDeployment: last ?? null })) return; + const label = (text: string) => color.dim(t(text).padEnd(14)); const rows = [ - `${color.dim('Application')} ${color.bold(fresh.name)} ${dot(fresh.status)} ${fresh.status ?? 'unknown'}`, - `${color.dim('Address ')} ${appUrl(fresh) ? color.cyan(appUrl(fresh)!) : '—'}`, - `${color.dim('Workspace ')} ${fresh.workspace_name ?? '—'}`, + `${label('Application')} ${color.bold(fresh.name)} ${dot(fresh.status)} ${fresh.status ?? t('unknown')}`, + `${label('Address')} ${appUrl(fresh) ? color.cyan(appUrl(fresh)!) : '—'}`, + `${label('Workspace')} ${fresh.workspace_name ?? '—'}`, last - ? `${color.dim('Deployment ')} ${last.status} ${color.dim(`${last.deployment_uuid.slice(0, 8)}${last.created_at ? ` · ${new Date(last.created_at).toLocaleString()}` : ''}`)}` - : `${color.dim('Deployment ')} none yet`, + ? `${label('Deployment')} ${last.status} ${color.dim(`${last.deployment_uuid.slice(0, 8)}${last.created_at ? ` · ${new Date(last.created_at).toLocaleString()}` : ''}`)}` + : `${label('Deployment')} ${t('none yet')}`, ]; - if (containers.trim()) rows.push(`${color.dim('Containers ')} ${containers.trim().split('\n').join('\n ')}`); - prompts.note(rows.join('\n'), 'Status'); + if (containers.trim()) rows.push(`${label('Containers')} ${containers.trim().split('\n').join('\n' + ' '.repeat(15))}`); + prompts.note(rows.join('\n'), t('Status')); } export async function open(opts: { app?: string; dashboard?: boolean }): Promise { const app = await resolveApp(opts.app); const url = opts.dashboard ? `${webUrl()}/applications/${app.uuid}` : appUrl(app); - if (!url) fail(`${app.name} has no address yet.`, `Deploy it with ${color.cyan('idem deploy')}, or open its page: idem open --dashboard`); + if (!url) fail(t('{name} has no address yet.', { name: app.name }), t('Deploy it with {command}, or open its page: idem open --dashboard', { command: color.cyan('idem deploy') })); if (!openBrowser(url)) process.stdout.write(url + '\n'); - else prompts.log.info(`Opened ${color.cyan(url)}`); + else prompts.log.info(t('Opened {url}', { url: color.cyan(url) })); } export async function restart(opts: { app?: string; yes?: boolean }): Promise { const app = await resolveApp(opts.app); if (!opts.yes && process.stdin.isTTY) { - const sure = await prompts.confirm({ message: `Restart ${app.name}? It is briefly unavailable.`, initialValue: true }); - if (prompts.isCancel(sure) || !sure) return void prompts.cancel('Not restarted.'); + const sure = await prompts.confirm({ message: t('Restart {name}? It is briefly unavailable.', { name: app.name }), initialValue: true }); + if (prompts.isCancel(sure) || !sure) return void prompts.cancel(t('Not restarted.')); } const spin = prompts.spinner(); - spin.start(`Restarting ${app.name}`); + spin.start(t('Restarting {name}', { name: app.name })); await api.post(`/applications/${app.uuid}/restart`); - spin.stop(`${app.name} restarted.`); + spin.stop(t('{name} restarted.', { name: app.name })); } export async function dashboard(): Promise { @@ -114,32 +116,32 @@ export async function dashboard(): Promise { export async function startStop(action: 'start' | 'stop', opts: { app?: string; yes?: boolean }): Promise { const app = await resolveApp(opts.app); - if (action === 'stop') await confirmOrStop(`Stop ${app.name}? It stops answering until started again.`, opts.yes); + if (action === 'stop') await confirmOrStop(t('Stop {name}? It stops answering until started again.', { name: app.name }), opts.yes); const spin = prompts.spinner(); - spin.start(`${action === 'start' ? 'Starting' : 'Stopping'} ${app.name}`); + spin.start(action === 'start' ? t('Starting {name}', { name: app.name }) : t('Stopping {name}', { name: app.name })); await api.post(`/applications/${app.uuid}/${action}`); - spin.stop(`${app.name} ${action === 'start' ? 'started' : 'stopped'}.`); + spin.stop(action === 'start' ? t('{name} started.', { name: app.name }) : t('{name} stopped.', { name: app.name })); } /** Deletes the application, its containers, volumes and images on its server. */ export async function destroy(opts: { app?: string; yes?: boolean }): Promise { const app = await resolveApp(opts.app); if (!opts.yes) { - if (!process.stdin.isTTY) fail(`Deleting ${app.name} needs --yes.`); - const typed = await prompts.text({ message: `Delete ${app.name}, its data and its address? Type its name to confirm` }); - if (prompts.isCancel(typed) || typed !== app.name) return void prompts.cancel('Nothing deleted.'); + if (!process.stdin.isTTY) fail(t('Deleting {name} needs --yes.', { name: app.name })); + const typed = await prompts.text({ message: t('Delete {name}, its data and its address? Type its name to confirm', { name: app.name }) }); + if (prompts.isCancel(typed) || typed !== app.name) return void prompts.cancel(t('Nothing deleted.')); } const r = await api.delete<{ serverCleanup?: string }>(`/applications/${app.uuid}`); if (readLink()?.applicationUuid === app.uuid) removeLink(); if (printJson({ removed: app.uuid, serverCleanup: r?.serverCleanup })) return; - prompts.log.success(`${app.name} deleted.${r?.serverCleanup === 'failed' ? color.yellow(' Its server could not be cleaned: it was unreachable.') : ''}`); + prompts.log.success(t('{name} deleted.', { name: app.name }) + (r?.serverCleanup === 'failed' ? ' ' + color.yellow(t('Its server could not be cleaned: it was unreachable.')) : '')); } export async function deployments(opts: { app?: string }): Promise { const app = await resolveApp(opts.app); const list = await api.get(`/applications/${app.uuid}/deployments`); if (printJson(list)) return; - if (list.length === 0) return void prompts.log.info(`${app.name} was never deployed.`); + if (list.length === 0) return void prompts.log.info(t('{name} was never deployed.', { name: app.name })); const mark = (s: string) => (s === 'finished' ? color.green('✓') : s === 'failed' ? color.red('✗') : color.yellow('…')); process.stdout.write( table(list.slice(0, 20).map((d) => [mark(d.status), d.deployment_uuid.slice(0, 8), d.status, d.commit && d.commit !== 'HEAD' ? d.commit.slice(0, 7) : '', color.dim(d.created_at ? new Date(d.created_at).toLocaleString() : '')])) + '\n' @@ -170,7 +172,7 @@ export async function domainsList(opts: { app?: string }): Promise { const guide = await api.get(`/applications/${app.uuid}/domains/dns`).catch(() => null); const domains = domainsOf(app); if (printJson(guide ?? domains)) return; - if (domains.length === 0) return void prompts.log.info(`${app.name} has no address yet.`); + if (domains.length === 0) return void prompts.log.info(t('{name} has no address yet.', { name: app.name })); const byHost = new Map((guide?.domains ?? []).map((d) => [d.host, d])); process.stdout.write( table( @@ -187,24 +189,24 @@ export async function domainsList(opts: { app?: string }): Promise { export async function domainsAdd(domain: string, opts: { app?: string }): Promise { const app = await resolveApp(opts.app); const host = bare(domain); - if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(host)) fail(`"${domain}" is not a domain name.`); + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(host)) fail(t('"{domain}" is not a domain name.', { domain })); const current = domainsOf(app); - if (current.some((d) => bare(d) === host)) return void prompts.log.info(`${host} is already one of its addresses.`); + if (current.some((d) => bare(d) === host)) return void prompts.log.info(t('{host} is already one of its addresses.', { host })); // Yours first: it becomes the main address. const updated = await api.patch(`/applications/${app.uuid}`, { fqdn: [`https://${host}`, ...current].join(',') }); if (printJson(updated)) return; - prompts.log.success(`${host} added to ${app.name}.`); + prompts.log.success(t('{host} added to {name}.', { host, name: app.name })); const guide = await api.get(`/applications/${app.uuid}/domains/dns?domains=${encodeURIComponent(host)}`).catch(() => null); const record = guide?.domains.find((d) => d.host === host)?.record; - if (record) prompts.note(`${record.type} ${record.name} → ${record.value}\n${color.dim(`in the DNS zone of ${record.zone}`)}`, 'At your domain registrar, create'); - prompts.log.info(`HTTPS is issued once the DNS points here. Check: ${color.cyan('idem domains')}`); + if (record) prompts.note(`${record.type} ${record.name} → ${record.value}\n${color.dim(t('in the DNS zone of {zone}', { zone: record.zone }))}`, t('At your domain registrar, create')); + prompts.log.info(t('HTTPS is issued once the DNS points here. Check: {command}', { command: color.cyan('idem domains') })); } export async function domainsRemove(domain: string, opts: { app?: string }): Promise { const app = await resolveApp(opts.app); const host = bare(domain); const kept = domainsOf(app).filter((d) => bare(d) !== host); - if (kept.length === domainsOf(app).length) fail(`${host} is not an address of ${app.name}.`); + if (kept.length === domainsOf(app).length) fail(t('{host} is not an address of {name}.', { host, name: app.name })); await api.patch(`/applications/${app.uuid}`, { fqdn: kept.join(',') }); - if (!printJson({ removed: host })) prompts.log.success(`${host} removed from ${app.name}.`); + if (!printJson({ removed: host })) prompts.log.success(t('{host} removed from {name}.', { host, name: app.name })); } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 9fe37b0..7ebd74e 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -7,6 +7,7 @@ import { api, ApiError, Me } from '../api.js'; import { apiUrl, clearConfig, readConfig, token, writeConfig } from '../config.js'; import { openBrowser, sleep } from '../shared.js'; import { color, fail, interactive, printJson, prompts } from '../ui.js'; +import { t } from '../i18n.js'; interface Started { deviceCode: string; @@ -42,14 +43,14 @@ function machine(): string { export async function browserLogin(): Promise { const started = await api.post('/cli/auth/start', { host: machine() }, { auth: false }); prompts.note( - `${color.bold(started.userCode)}\n\n${color.dim('Check that the page shows the same code, then authorise.')}\n${color.cyan(started.verificationUrl)}`, - 'Sign in to iDeploy' + `${color.bold(started.userCode)}\n\n${color.dim(t('Check that the page shows the same code, then authorise.'))}\n${color.cyan(started.verificationUrl)}`, + t('Sign in to iDeploy') ); const opened = openBrowser(started.verificationUrl); - if (!opened) prompts.log.info('Open the link above in a browser (on any device).'); + if (!opened) prompts.log.info(t('Open the link above in a browser (on any device).')); const spinner = prompts.spinner(); - spinner.start('Waiting for your approval in the browser'); + spinner.start(t('Waiting for your approval in the browser')); const deadline = Date.now() + started.expiresIn * 1000; while (Date.now() < deadline) { await sleep(started.interval * 1000); @@ -61,17 +62,17 @@ export async function browserLogin(): Promise { } if (answer.status === 'approved' && answer.token) { const me = await saveSignedIn(answer.token); - spinner.stop(`Signed in as ${color.bold(me.name)}${me.team ? ` · team ${me.team.name}` : ''}`); + spinner.stop(t('Signed in as {name}', { name: color.bold(me.name) }) + (me.team ? ` · ${t('team {team}', { team: me.team.name })}` : '')); return me; } if (answer.status === 'denied') { - spinner.stop('Refused in the browser.', 1); - fail('Sign-in refused.'); + spinner.stop(t('Refused in the browser.'), 1); + fail(t('Sign-in refused.')); } if (answer.status === 'expired') break; } - spinner.stop('The code expired.', 1); - fail('Sign-in timed out.', 'Run `idem login` again.'); + spinner.stop(t('The code expired.'), 1); + fail(t('Sign-in timed out.'), t('Run `idem login` again.')); } export async function login(opts: { token?: string; api?: string }): Promise { @@ -80,36 +81,36 @@ export async function login(opts: { token?: string; api?: string }): Promise { const current = readConfig().token; if (!current) { - prompts.log.info('You were not signed in.'); + prompts.log.info(t('You were not signed in.')); return; } // `|`: the token is revoked on iDeploy too, not only forgotten here. const id = Number(current.split('|')[0]); if (id > 0) await api.delete(`/security/api-tokens/${id}`).catch(() => undefined); clearConfig(); - prompts.log.success('Signed out. This machine\'s token is revoked.'); + prompts.log.success(t("Signed out. This machine's token is revoked.")); } export async function whoami(): Promise { - if (!token()) fail('You are not signed in.', 'Run `idem login`.'); + if (!token()) fail(t('You are not signed in.'), t('Run `idem login`.')); const me = await api.get('/me'); if (printJson({ name: me.name, email: me.email, team: me.team?.name ?? null, api: apiUrl() })) return; - prompts.log.info(`${color.bold(me.name)} <${me.email}>${me.team ? ` · team ${color.bold(me.team.name)}` : ''}\n${color.dim(apiUrl())}`); + prompts.log.info(`${color.bold(me.name)} <${me.email}>${me.team ? ` · ${t('team {team}', { team: color.bold(me.team.name) })}` : ''}\n${color.dim(apiUrl())}`); } diff --git a/src/commands/databases.ts b/src/commands/databases.ts index be5a8af..d3242af 100644 --- a/src/commands/databases.ts +++ b/src/commands/databases.ts @@ -7,6 +7,7 @@ import { api } from '../api.js'; import { confirmOrStop, pick, requireSignedIn, table } from '../shared.js'; import { color, fail, interactive, printJson, prompts, unlessCancelled } from '../ui.js'; import { workspaceOf } from './workspaces.js'; +import { t } from '../i18n.js'; export const ENGINES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'keydb', 'dragonfly', 'clickhouse', 'rabbitmq'] as const; @@ -42,8 +43,8 @@ export async function dbList(): Promise { requireSignedIn(); const list = await listDatabases(); if (printJson(list.map((d) => ({ uuid: d.uuid, name: d.name, type: d.type, status: d.status })))) return; - if (list.length === 0) return void prompts.log.info(`No database yet: ${color.cyan('idem db create')}.`); - process.stdout.write(table([[' ', color.dim('NAME'), color.dim('ENGINE'), color.dim('STATUS'), color.dim('ID')], ...list.map((d) => [dot(d.status), d.name, d.type, d.status ?? '—', color.dim(d.uuid.slice(0, 8))])]) + '\n'); + if (list.length === 0) return void prompts.log.info(t('No database yet: {command}.', { command: color.cyan('idem db create') })); + process.stdout.write(table([[' ', color.dim(t('NAME')), color.dim(t('ENGINE')), color.dim(t('STATUS')), color.dim(t('ID'))], ...list.map((d) => [dot(d.status), d.name, d.type, d.status ?? '—', color.dim(d.uuid.slice(0, 8))])]) + '\n'); } export async function dbCreate(engine: string | undefined, opts: { name?: string; workspace?: string; start?: boolean }): Promise { @@ -51,13 +52,13 @@ export async function dbCreate(engine: string | undefined, opts: { name?: string let type = engine?.toLowerCase(); if (type === 'postgres' || type === 'pg') type = 'postgresql'; if (!type) { - if (!interactive()) fail(`Which engine? ${ENGINES.join(', ')}`); - type = String(unlessCancelled(await prompts.select({ message: 'Engine', options: ENGINES.map((e) => ({ value: e, label: e })) }))); + if (!interactive()) fail(t('Which engine? {list}', { list: ENGINES.join(', ') })); + type = String(unlessCancelled(await prompts.select({ message: t('Engine'), options: ENGINES.map((e) => ({ value: e, label: e })) }))); } - if (!(ENGINES as readonly string[]).includes(type)) fail(`Unknown engine "${type}".`, `One of: ${ENGINES.join(', ')}`); + if (!(ENGINES as readonly string[]).includes(type)) fail(t('Unknown engine "{type}".', { type }), t('One of: {list}', { list: ENGINES.join(', ') })); const ws = await workspaceOf(opts.workspace); const name = - opts.name ?? (interactive() ? String(unlessCancelled(await prompts.text({ message: 'Name', initialValue: `${ws.name}-${type}` }))) : `${ws.name}-${type}`); + opts.name ?? (interactive() ? String(unlessCancelled(await prompts.text({ message: t('Name'), initialValue: `${ws.name}-${type}` }))) : `${ws.name}-${type}`); const created = await api.post(`/databases/${type}`, { name, workspace_uuid: ws.uuid }); if (opts.start !== false) { @@ -67,8 +68,8 @@ export async function dbCreate(engine: string | undefined, opts: { name?: string spin?.stop(r.success === false ? `${name} created, but did not start.` : `${name} is running.`, r.success === false ? 1 : 0); } if (printJson(created)) return; - prompts.log.success(`${color.bold(name)} (${type}) in workspace ${ws.name}. Its credentials: ${color.cyan(`idem db show ${name}`)}`); - prompts.log.info(`The applications of ${ws.name} get its address at their next ${color.cyan('idem deploy')} (example values only).`); + prompts.log.success(t('{name} ({type}) in workspace {workspace}. Its credentials: {command}', { name: color.bold(name), type, workspace: ws.name, command: color.cyan(`idem db show ${name}`) })); + prompts.log.info(t('The applications of {workspace} get its address at their next {command} (example values only).', { workspace: ws.name, command: color.cyan('idem deploy') })); } export async function dbShow(wanted: string | undefined, opts: { reveal?: boolean }): Promise { @@ -79,43 +80,43 @@ export async function dbShow(wanted: string | undefined, opts: { reveal?: boolea const creds = Object.entries(d.credentials ?? {}).map(([k, v]) => `${color.dim(k.padEnd(24))} ${opts.reveal || !/pass|secret|key/i.test(k) ? v : '••••'}`); prompts.note( [ - `${color.dim('Engine'.padEnd(24))} ${d.type} ${dot(d.status)} ${d.status ?? ''}`, - `${color.dim('Inside the workspace'.padEnd(24))} ${d.internal_host}:${d.port}`, - `${color.dim('Connection URL'.padEnd(24))} ${hide(d.connection_url) ?? '—'}`, - ...(d.public_connection_url ? [`${color.dim('From outside'.padEnd(24))} ${hide(d.public_connection_url)}`] : []), + `${color.dim(t('Engine').padEnd(24))} ${d.type} ${dot(d.status)} ${d.status ?? ''}`, + `${color.dim(t('Inside the workspace').padEnd(24))} ${d.internal_host}:${d.port}`, + `${color.dim(t('Connection URL').padEnd(24))} ${hide(d.connection_url) ?? '—'}`, + ...(d.public_connection_url ? [`${color.dim(t('From outside').padEnd(24))} ${hide(d.public_connection_url)}`] : []), '', ...creds, ].join('\n'), d.name ); - if (!opts.reveal) prompts.log.info(color.dim('Secrets hidden — --reveal shows them.')); + if (!opts.reveal) prompts.log.info(color.dim(t('Secrets hidden — --reveal shows them.'))); } export async function dbAction(action: 'start' | 'stop' | 'restart', wanted: string | undefined): Promise { const db = await databaseOf(wanted); const spin = prompts.spinner(); - spin.start(`${action === 'start' ? 'Starting' : action === 'stop' ? 'Stopping' : 'Restarting'} ${db.name}`); + spin.start(t(action === 'start' ? 'Starting {name}' : action === 'stop' ? 'Stopping {name}' : 'Restarting {name}', { name: db.name })); const r = await api.post<{ success?: boolean; output?: string }>(`/databases/${db.type}/${db.uuid}/${action}`); if (r.success === false) { - spin.stop(`${db.name}: ${action} failed.`, 1); + spin.stop(t('{name}: {action} failed.', { name: db.name, action }), 1); fail(r.output?.split('\n').slice(-6).join('\n') || `${action} failed.`); } - spin.stop(`${db.name}: done.`); + spin.stop(t('{name}: done.', { name: db.name })); } export async function dbBackup(wanted: string | undefined): Promise { const db = await databaseOf(wanted); const spin = prompts.spinner(); - spin.start(`Backing up ${db.name}`); + spin.start(t('Backing up {name}', { name: db.name })); const r = await api.post<{ success: boolean; filename: string; output: string }>(`/databases/${db.type}/${db.uuid}/backup-now`); - spin.stop(r.success ? `Backup made: ${r.filename}` : 'The backup failed.', r.success ? 0 : 1); + spin.stop(r.success ? t('Backup made: {file}', { file: r.filename }) : t('The backup failed.'), r.success ? 0 : 1); if (!r.success) fail(r.output.split('\n').slice(-6).join('\n') || 'Backup failed.'); printJson(r); } export async function dbRemove(wanted: string | undefined, opts: { yes?: boolean }): Promise { const db = await databaseOf(wanted); - await confirmOrStop(`Delete ${db.name} (${db.type}) and all its data? This cannot be undone.`, opts.yes); + await confirmOrStop(t('Delete {name} ({type}) and all its data? This cannot be undone.', { name: db.name, type: db.type }), opts.yes); await api.delete(`/databases/${db.type}/${db.uuid}`); - if (!printJson({ removed: db.uuid })) prompts.log.success(`${db.name} deleted.`); + if (!printJson({ removed: db.uuid })) prompts.log.success(t('{name} deleted.', { name: db.name })); } diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index d6a6ec5..e83e631 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -22,6 +22,7 @@ import { appNameFrom, elapsed, followDeployment, listApps, readEnvFile, resolveA import { appUrl, color, fail, interactive, isJson, printJson, prompts, unlessCancelled } from '../ui.js'; import { browserLogin } from './auth.js'; import { listWorkspaces, placement } from './workspaces.js'; +import { t } from '../i18n.js'; export interface DeployOptions { app?: string; @@ -67,7 +68,7 @@ const NEW_WORKSPACE = '__new__'; function sourceOf(opts: DeployOptions): Source { const given = (['git', 'image', 'compose', 'template'] as const).filter((k) => opts[k]); - if (given.length > 1) fail(`Choose one source: --${given.join(', --')}.`); + if (given.length > 1) fail(t('Choose one source: {sources}.', { sources: `--${given.join(', --')}` })); return given[0] ?? 'folder'; } @@ -84,7 +85,7 @@ export async function deploy(opts: DeployOptions): Promise { // 1. Signed in — through the browser, right here, the first time. if (!token()) { - if (!interactive()) fail('You are not signed in.', 'In CI, set IDEM_TOKEN (Security › API tokens on iDeploy).'); + if (!interactive()) fail(t('You are not signed in.'), t('In CI, set IDEM_TOKEN (Security › API tokens on iDeploy).')); await browserLogin(); } @@ -112,7 +113,7 @@ async function linkedOrSameName(cwd: string, ask: boolean): Promise ({ value: a.uuid, label: `Yes — ${a.name}`, hint: a.workspace_name ?? undefined })), - { value: '', label: 'No — create a new application' }, + ...same.map((a) => ({ value: a.uuid, label: t('Yes — {name}', { name: a.name }), hint: a.workspace_name ?? undefined })), + { value: '', label: t('No — create a new application') }, ], }) ); @@ -136,21 +137,21 @@ async function linkedOrSameName(cwd: string, ask: boolean): Promise ` ${h.dir} ${formatBytes(h.bytes)}`).join('\n'); fail( - `The project is ${formatBytes(pack.bytes)} in ${pack.count} files: the CLI sends up to ${formatBytes(MAX_BYTES)} and ${MAX_FILES} files.`, - `The heaviest folders:\n${heavy}\nAdd what the build does not need to .idemignore (as .gitignore), or deploy from Git: idem deploy --git .` + t('The project is {size} in {count} files: the CLI sends up to {max} and {maxFiles} files.', { size: formatBytes(pack.bytes), count: pack.count, max: formatBytes(MAX_BYTES), maxFiles: MAX_FILES }), + t('The heaviest folders:\n{list}\nAdd what the build does not need to .idemignore (as .gitignore), or deploy from Git: idem deploy --git .', { list: heavy }) ); } - spin?.stop(`Project read: ${pack.count} files, ${formatBytes(pack.bytes)}`); + spin?.stop(t('Project read: {count} files, {size}', { count: pack.count, size: formatBytes(pack.bytes) })); return pack; } @@ -162,20 +163,20 @@ async function redeploy(app: Application, opts: DeployOptions, started: number): if ((fromGit || fromImage) && !opts.upload) { // Its source is a repository or an image: sending this folder would replace it. - const what = fromImage ? `its image${opts.tag ? ` (tag ${opts.tag})` : ''}` : `its repository${app.git_repository ? ` ${color.dim(app.git_repository)}` : ''}`; - if (!isJson()) prompts.log.info(`${color.bold(app.name)} deploys from ${what}. ${color.dim('(--upload sends this folder instead)')}`); + const what = fromImage ? t('its image') + (opts.tag ? ` (tag ${opts.tag})` : '') : t('its repository') + (app.git_repository ? ` ${color.dim(app.git_repository)}` : ''); + if (!isJson()) prompts.log.info(`${t('{name} deploys from {what}.', { name: color.bold(app.name), what })} ${color.dim(t('(--upload sends this folder instead)'))}`); const queued = await api.post<{ deploymentUuid: string }>('/deploy', { uuid: app.uuid, ...(opts.tag ? { image_tag: opts.tag } : {}) }); deploymentUuid = queued.deploymentUuid; } else { const pack = readProject(process.cwd()); const spin = isJson() ? null : prompts.spinner(); - spin?.start(`Sending the code to ${color.bold(app.name)}`); + spin?.start(t('Sending the code to {name}', { name: color.bold(app.name) })); const sent = await api.put<{ deploymentUuid: string | null }>(`/applications/${app.uuid}/source`, { files: pack.files, deploy: true }); if (!sent.deploymentUuid) { - spin?.stop('Code sent, but no deployment was queued.', 1); - fail('iDeploy did not queue a deployment.'); + spin?.stop(t('Code sent, but no deployment was queued.'), 1); + fail(t('iDeploy did not queue a deployment.')); } - spin?.stop(`Code sent to ${color.bold(app.name)}`); + spin?.stop(t('Code sent to {name}', { name: color.bold(app.name) })); deploymentUuid = sent.deploymentUuid; } await follow(app.uuid, app.name, deploymentUuid, appUrl(app), opts, started); @@ -197,13 +198,13 @@ async function workspaceFor( if (workspaces.length > 0) { const choice = unlessCancelled( await prompts.select({ - message: 'Workspace (its databases are linked automatically)', + message: t('Workspace (its databases are linked automatically)'), options: [ - { value: NEW_WORKSPACE, label: `New workspace "${name}"` }, + { value: NEW_WORKSPACE, label: t('New workspace "{name}"', { name }) }, ...workspaces.map((w) => ({ value: w.uuid, label: w.name, - hint: w.deploymentType === 'own' ? `your server ${w.assignedServerName ?? ''}`.trim() : 'IDEM', + hint: w.deploymentType === 'own' ? t('your server {name}', { name: w.assignedServerName ?? '' }).trim() : 'IDEM', })), ], }) @@ -218,16 +219,16 @@ async function workspaceFor( async function variablesFor(opts: DeployOptions, ask: boolean): Promise<{ key: string; value: string }[]> { const file = typeof opts.env === 'string' ? resolve(opts.env) : join(process.cwd(), '.env'); const local = readEnvFile(file); - if (typeof opts.env === 'string' && !local) fail(`Cannot read ${opts.env}.`); + if (typeof opts.env === 'string' && !local) fail(t('Cannot read {file}.', { file: String(opts.env) })); if (!local || local.length === 0) return []; if (opts.env) return local; if (!ask) return []; const send = unlessCancelled( await prompts.confirm({ - message: `Send the ${local.length} variables of .env? (${local - .slice(0, 4) - .map((v) => v.key) - .join(', ')}${local.length > 4 ? '…' : ''}) — iDeploy replaces local addresses by its own databases`, + message: t('Send the {count} variables of .env? ({keys}) — iDeploy replaces local addresses by its own databases', { + count: local.length, + keys: local.slice(0, 4).map((v) => v.key).join(', ') + (local.length > 4 ? '…' : ''), + }), initialValue: true, }) ); @@ -239,7 +240,7 @@ async function nameFor(opts: DeployOptions, ask: boolean, fallback: string): Pro if (ask && !opts.name) { name = appNameFrom( unlessCancelled( - await prompts.text({ message: 'Name', initialValue: name, validate: (v) => (appNameFrom(v) ? undefined : 'Letters, digits and dashes.') }) + await prompts.text({ message: t('Name'), initialValue: name, validate: (v) => (appNameFrom(v) ? undefined : t('Letters, digits and dashes.')) }) ) ); } @@ -261,10 +262,10 @@ async function createApplication(source: Source, opts: DeployOptions, ask: boole const [image, imageTag] = opts.image ? splitImage(opts.image) : [undefined, undefined]; const registry_password = process.env.IDEM_REGISTRY_PASSWORD; - if (opts.registryUser && !registry_password) fail('Give the registry password in IDEM_REGISTRY_PASSWORD (never on the command line).'); + if (opts.registryUser && !registry_password) fail(t('Give the registry password in IDEM_REGISTRY_PASSWORD (never on the command line).')); const spin = isJson() ? null : prompts.spinner(); - spin?.start(`Creating ${color.bold(name)}`); + spin?.start(t('Creating {name}', { name: color.bold(name) })); const created = await api.post('/quick-deploy', { name, ...workspace, @@ -282,17 +283,14 @@ async function createApplication(source: Source, opts: DeployOptions, ask: boole publish_directory: opts.publishDir, }); if (!created.applicationUuid || !created.deploymentUuid) { - spin?.stop('Not created.', 1); - fail('iDeploy did not create the application.'); + spin?.stop(t('Not created.'), 1); + fail(t('iDeploy did not create the application.')); } - spin?.stop(`Created ${color.bold(name)} in workspace ${color.bold(created.workspace.name)}`); + spin?.stop(t('Created {name} in workspace {workspace}', { name: color.bold(name), workspace: color.bold(created.workspace.name) })); if (source === 'folder') { writeLink({ applicationUuid: created.applicationUuid, applicationName: name, workspaceUuid: created.workspace.uuid, workspaceName: created.workspace.name }); } - if (created.linkedVariables?.length && !isJson()) { - prompts.log.info(`Linked to the workspace: ${created.linkedVariables.map((v) => `${color.bold(v.key)} → ${v.resource}`).join(', ')}`); - } - await follow(created.applicationUuid, name, created.deploymentUuid, created.url ?? null, opts, started); + await follow(created.applicationUuid, name, created.deploymentUuid, created.url ?? null, opts, started, created.linkedVariables); } /** A compose file or a catalogue template: a service, started at once. */ @@ -304,10 +302,10 @@ async function createService(source: 'compose' | 'template', opts: DeployOptions const spin = isJson() ? null : prompts.spinner(); if (source === 'template') { - spin?.start(`Deploying the template ${color.bold(opts.template!)}`); + spin?.start(t('Deploying the template {name}', { name: color.bold(opts.template!) })); const created = await api.post('/quick-deploy', { name, template: opts.template, ...workspace, environment_variables }); - spin?.stop(`${color.bold(name)} is starting in workspace ${color.bold(created.workspace.name)}`); - if (!printJson(created)) prompts.outro(`Its address and passwords: ${color.cyan('idem services')} · page on iDeploy: ${color.cyan('idem dashboard')} ${color.dim(`(${elapsed(started)})`)}`); + spin?.stop(t('{name} is starting in workspace {workspace}', { name: color.bold(name), workspace: color.bold(created.workspace.name) })); + if (!printJson(created)) prompts.outro(`${t('Its address and passwords: {a} · page on iDeploy: {b}', { a: color.cyan('idem services'), b: color.cyan('idem dashboard') })} ${color.dim(`(${elapsed(started)})`)}`); return; } @@ -315,7 +313,7 @@ async function createService(source: 'compose' | 'template', opts: DeployOptions try { compose = readFileSync(resolve(opts.compose!), 'utf8'); } catch { - fail(`Cannot read ${opts.compose}.`); + fail(t('Cannot read {file}.', { file: opts.compose! })); } // A service goes into an existing workspace: a new one is created first. if (!workspace.workspace_uuid) { @@ -323,22 +321,22 @@ async function createService(source: 'compose' | 'template', opts: DeployOptions const created = await api.post<{ uuid: string; name: string }>('/workspaces', { name: workspace_name, ...where }); workspace = { workspace_uuid: created.uuid }; } - spin?.start(`Creating the service ${color.bold(name)}`); + spin?.start(t('Creating the service {name}', { name: color.bold(name) })); const service = await api.post<{ uuid: string; name: string }>('/services', { name, workspace_uuid: workspace.workspace_uuid, docker_compose_raw: compose, environment_variables, }); - spin?.message(`Starting ${color.bold(name)} (pulling its images)`); + spin?.message(t('Starting {name} (pulling its images)', { name: color.bold(name) })); const result = await api.post<{ success?: boolean; output?: string }>(`/services/${service.uuid}/start`); if (result.success === false) { - spin?.stop(`${name} did not start.`, 1); + spin?.stop(t('{name} did not start.', { name }), 1); if (result.output && !isJson()) process.stdout.write(color.dim(result.output.split('\n').slice(-20).join('\n')) + '\n'); - fail('The service failed to start — the end of its output is above.', `Fix the compose file, then: idem services restart ${name}`); + fail(t('The service failed to start — the end of its output is above.'), t('Fix the compose file, then: idem services restart {name}', { name })); } - spin?.stop(`${color.bold(name)} is running.`); - if (!printJson({ serviceUuid: service.uuid, name })) prompts.outro(`${color.green('Running')} ${color.dim(`(${elapsed(started)})`)} · ${color.cyan('idem services')}`); + spin?.stop(t('{name} is running.', { name: color.bold(name) })); + if (!printJson({ serviceUuid: service.uuid, name })) prompts.outro(`${color.green(t('Running'))} ${color.dim(`(${elapsed(started)})`)} · ${color.cyan('idem services')}`); } /** `registry/org/app:v1` → image and tag (a port in the registry host is not a tag). */ @@ -347,26 +345,44 @@ function splitImage(ref: string): [string, string | undefined] { return colon > ref.lastIndexOf('/') ? [ref.slice(0, colon), ref.slice(colon + 1)] : [ref, undefined]; } -/** Followed to its end, live — or handed back at once with --detach. */ -async function follow(applicationUuid: string, name: string, deploymentUuid: string, url: string | null, opts: DeployOptions, started: number): Promise { +/** Followed to its end, live — or handed back at once with --detach. Ends with a summary. */ +async function follow( + applicationUuid: string, + name: string, + deploymentUuid: string, + url: string | null, + opts: DeployOptions, + started: number, + linked: { key: string; resource: string }[] = [] +): Promise { if (opts.detach) { if (!printJson({ deploymentUuid, applicationUuid, url })) { - prompts.outro(`Deployment queued. Follow it with ${color.cyan('idem status')}${url ? ` · ${color.cyan(url)}` : ''}`); + prompts.outro(t('Deployment queued. Follow it with {command}', { command: color.cyan('idem status') }) + (url ? ` · ${color.cyan(url)}` : '')); } return; } - if (!isJson()) prompts.log.step(`Deploying ${color.bold(name)} ${color.dim(`(${deploymentUuid.slice(0, 8)})`)}`); + if (!isJson()) prompts.log.step(`${t('Deploying {name}', { name: color.bold(name) })} ${color.dim(`(${deploymentUuid.slice(0, 8)})`)}`); const final = await followDeployment(deploymentUuid, { quiet: isJson() }); const address = final.application_url ?? url; if (final.status === 'finished') { - if (!printJson({ status: 'finished', deploymentUuid, applicationUuid, url: address })) { - prompts.outro(`${color.green('Live')}${address ? ` at ${color.cyan(color.bold(address))}` : ''} ${color.dim(`(${elapsed(started)})`)}`); - } + if (printJson({ status: 'finished', deploymentUuid, applicationUuid, url: address, linkedVariables: linked })) return; + const label = (text: string) => color.dim(t(text).padEnd(14)); + prompts.note( + [ + `${label('Address')} ${address ? color.cyan(color.bold(address)) : '—'}`, + `${label('Duration')} ${elapsed(started)}`, + ...(linked.length ? [`${label('Linked')} ${linked.map((v) => `${v.key} → ${v.resource}`).join(', ')}`] : []), + '', + `${label('Next')} ${color.cyan('idem logs -f')} ${color.cyan('idem open')} ${color.cyan('idem status')}`, + ].join('\n'), + color.green(t('{name} is live', { name })) + ); + prompts.outro(t('Done.')); return; } if (printJson({ status: final.status, deploymentUuid, applicationUuid, url: address })) process.exit(1); fail( - final.status === 'cancelled-by-user' ? 'The deployment was cancelled.' : 'The deployment failed — the cause is in the log above.', - `The previous version, if any, is still the one serving. Logs: ${color.cyan('idem logs')} · page: ${color.cyan('idem open --dashboard')}` + final.status === 'cancelled-by-user' ? t('The deployment was cancelled.') : t('The deployment failed — the cause is in the log above.'), + t('The previous version, if any, is still the one serving. Logs: {logs} · page: {page}', { logs: color.cyan('idem logs'), page: color.cyan('idem open --dashboard') }) ); } diff --git a/src/commands/env.ts b/src/commands/env.ts index cb53301..5baf1cf 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -3,6 +3,7 @@ import { existsSync, writeFileSync } from 'node:fs'; import { api, EnvVar } from '../api.js'; import { parseEnv, resolveApp } from '../shared.js'; import { color, fail, interactive, printJson, prompts } from '../ui.js'; +import { t } from '../i18n.js'; function mask(value: string): string { if (!value) return color.dim('(empty)'); @@ -13,27 +14,27 @@ export async function envList(opts: { app?: string; reveal?: boolean }): Promise const app = await resolveApp(opts.app); const vars = await api.get(`/applications/${app.uuid}/envs`); if (printJson(opts.reveal ? vars.map((v) => ({ key: v.key, value: v.value })) : vars.map((v) => ({ key: v.key })))) return; - if (vars.length === 0) return void prompts.log.info(`${app.name} has no variable.`); + if (vars.length === 0) return void prompts.log.info(t('{name} has no variable.', { name: app.name })); const width = Math.min(Math.max(...vars.map((v) => v.key.length)), 40); process.stdout.write(vars.map((v) => `${v.key.padEnd(width)} ${opts.reveal ? v.value : mask(v.value)}`).join('\n') + '\n'); - if (!opts.reveal) prompts.log.info(color.dim('Values hidden — --reveal shows them.')); + if (!opts.reveal) prompts.log.info(color.dim(t('Values hidden — --reveal shows them.'))); } export async function envSet(pairs: string[], opts: { app?: string }): Promise { const parsed = parseEnv(pairs.join('\n')); - if (parsed.length === 0 || parsed.length !== pairs.length) fail('Give variables as KEY=value.', 'Example: idem env set APP_ENV=production MAIL_HOST=smtp.example.com'); + if (parsed.length === 0 || parsed.length !== pairs.length) fail(t('Give variables as KEY=value.'), t('Example: idem env set APP_ENV=production MAIL_HOST=smtp.example.com')); const app = await resolveApp(opts.app); for (const v of parsed) await api.post(`/applications/${app.uuid}/envs`, { key: v.key, value: v.value, is_runtime: true }); if (printJson({ set: parsed.map((v) => v.key) })) return; - prompts.log.success(`Set ${parsed.map((v) => color.bold(v.key)).join(', ')} on ${app.name}.`); - prompts.log.info(`They apply at the next deployment: ${color.cyan('idem deploy')} (or redeploy from iDeploy).`); + prompts.log.success(t('Set {keys} on {name}.', { keys: parsed.map((v) => color.bold(v.key)).join(', '), name: app.name })); + prompts.log.info(t('They apply at the next deployment: {command} (or redeploy from iDeploy).', { command: color.cyan('idem deploy') })); } export async function envRemove(keys: string[], opts: { app?: string }): Promise { - if (keys.length === 0) fail('Which variable?', 'Example: idem env rm OLD_KEY'); + if (keys.length === 0) fail(t('Which variable?'), t('Example: idem env rm OLD_KEY')); const app = await resolveApp(opts.app); for (const key of keys) await api.delete(`/applications/${app.uuid}/envs/${encodeURIComponent(key)}`); - if (!printJson({ removed: keys })) prompts.log.success(`Removed ${keys.map((k) => color.bold(k)).join(', ')} from ${app.name}.`); + if (!printJson({ removed: keys })) prompts.log.success(t('Removed {keys} from {name}.', { keys: keys.map((k) => color.bold(k)).join(', '), name: app.name })); } /** The deployed values in a local file — never over an existing one without asking. */ @@ -41,12 +42,12 @@ export async function envPull(file: string | undefined, opts: { app?: string; ye const target = file ?? '.env'; const app = await resolveApp(opts.app); if (existsSync(target) && !opts.yes) { - if (!interactive()) fail(`${target} exists already.`, 'Pass --yes to replace it, or give another file name.'); - const replace = await prompts.confirm({ message: `${target} exists. Replace it?`, initialValue: false }); - if (prompts.isCancel(replace) || !replace) return void prompts.cancel('Kept as it was.'); + if (!interactive()) fail(t('{file} exists already.', { file: target }), t('Pass --yes to replace it, or give another file name.')); + const replace = await prompts.confirm({ message: t('{file} exists. Replace it?', { file: target }), initialValue: false }); + if (prompts.isCancel(replace) || !replace) return void prompts.cancel(t('Kept as it was.')); } const vars = await api.get(`/applications/${app.uuid}/envs`); const quote = (v: string) => (/[\s#"'$\\]/.test(v) ? `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"` : v); writeFileSync(target, `# ${app.name} — written by idem env pull\n` + vars.map((v) => `${v.key}=${quote(v.value)}`).join('\n') + '\n', { mode: 0o600 }); - if (!printJson({ file: target, count: vars.length })) prompts.log.success(`${vars.length} variables written to ${target}. Keep it out of Git.`); + if (!printJson({ file: target, count: vars.length })) prompts.log.success(t('{count} variables written to {file}. Keep it out of Git.', { count: vars.length, file: target })); } diff --git a/src/commands/logs.ts b/src/commands/logs.ts index 1af5a00..71f9be7 100644 --- a/src/commands/logs.ts +++ b/src/commands/logs.ts @@ -2,6 +2,7 @@ import { api } from '../api.js'; import { resolveApp, sleep } from '../shared.js'; import { color, fail, isJson } from '../ui.js'; +import { t } from '../i18n.js'; interface LogsAnswer { container: string | null; @@ -31,7 +32,7 @@ export async function logs(opts: { app?: string; follow?: boolean; lines?: strin const app = await resolveApp(opts.app); const tail = Math.min(Math.max(Number(opts.lines) || 200, 1), 2000); const first = await api.get(`/applications/${app.uuid}/logs?tail=${tail}`); - if (!first.container) fail(`${app.name} is not running: no container to read.`, 'Deploy it with `idem deploy`, or see `idem status`.'); + if (!first.container) fail(t('{name} is not running: no container to read.', { name: app.name }), t('Deploy it with `idem deploy`, or see `idem status`.')); let lines = first.logs.split('\n').filter(Boolean); print(lines); if (!opts.follow) return; @@ -44,7 +45,7 @@ export async function logs(opts: { app?: string; follow?: boolean; lines?: strin const since = last ? `&since=${encodeURIComponent(last)}` : ''; const next = await api.get(`/applications/${app.uuid}/logs?tail=2000${since}`).catch(() => null); if (!next) continue; - if (!next.container) fail(`${app.name} stopped.`); + if (!next.container) fail(t('{name} stopped.', { name: app.name })); lines = next.logs.split('\n').filter(Boolean).filter((l) => !seenAtLast.has(l)); if (lines.length === 0) continue; print(lines); diff --git a/src/commands/menu.ts b/src/commands/menu.ts index dc66a6b..6f4cde6 100644 --- a/src/commands/menu.ts +++ b/src/commands/menu.ts @@ -16,6 +16,7 @@ import { domainsList } from './apps.js'; import { dbList } from './databases.js'; import { workspacesList } from './workspaces.js'; import { serversList } from './servers.js'; +import { t } from '../i18n.js'; type Action = 'deploy' | 'logs' | 'status' | 'open' | 'restart' | 'rollback' | 'env' | 'domains' | 'apps' | 'link' | 'db' | 'workspaces' | 'servers' | 'dashboard' | 'logout' | 'quit'; @@ -24,30 +25,37 @@ export async function menu(): Promise { if (!token()) await browserLogin(); const linked = readLink(); const here = linked ? `${color.bold(linked.applicationName)}` : color.bold(basename(process.cwd())); + // A folder deployed nowhere yet: the one thing to know first. + if (!linked) { + prompts.note( + `${t('This folder is not deployed yet. One command does it all:')}\n\n ${color.cyan(color.bold('idem deploy'))}\n\n${color.dim(t('Or pick something below. Every command: idem --help'))}`, + t('Welcome') + ); + } const options: { value: Action; label: string; hint?: string }[] = [ - { value: 'deploy', label: linked ? `Deploy ${linked.applicationName}` : `Deploy this folder`, hint: 'idem deploy' }, + { value: 'deploy', label: linked ? t('Deploy {name}', { name: linked.applicationName }) : t('Deploy this folder'), hint: 'idem deploy' }, ...(linked ? ([ - { value: 'logs', label: 'Logs, live', hint: 'idem logs -f' }, - { value: 'status', label: 'Status', hint: 'idem status' }, - { value: 'open', label: 'Open in the browser', hint: 'idem open' }, - { value: 'env', label: 'Variables', hint: 'idem env' }, - { value: 'domains', label: 'Domains', hint: 'idem domains' }, - { value: 'restart', label: 'Restart', hint: 'idem restart' }, - { value: 'rollback', label: 'Back to an earlier version', hint: 'idem rollback' }, + { value: 'logs', label: t('Logs, live'), hint: 'idem logs -f' }, + { value: 'status', label: t('Status'), hint: 'idem status' }, + { value: 'open', label: t('Open in the browser'), hint: 'idem open' }, + { value: 'env', label: t('Variables'), hint: 'idem env' }, + { value: 'domains', label: t('Domains'), hint: 'idem domains' }, + { value: 'restart', label: t('Restart'), hint: 'idem restart' }, + { value: 'rollback', label: t('Back to an earlier version'), hint: 'idem rollback' }, ] as { value: Action; label: string; hint?: string }[]) - : [{ value: 'link' as Action, label: 'Link this folder to an existing application', hint: 'idem link' }]), - { value: 'apps', label: 'All my applications', hint: 'idem apps' }, - { value: 'db', label: 'Databases, caches, queues', hint: 'idem db' }, - { value: 'workspaces', label: 'Workspaces', hint: 'idem workspaces' }, - { value: 'servers', label: 'My servers', hint: 'idem servers' }, - { value: 'dashboard', label: 'Open iDeploy', hint: 'idem dashboard' }, - { value: 'logout', label: 'Sign out', hint: 'idem logout' }, - { value: 'quit', label: 'Quit' }, + : [{ value: 'link' as Action, label: t('Link this folder to an existing application'), hint: 'idem link' }]), + { value: 'apps', label: t('All my applications'), hint: 'idem apps' }, + { value: 'db', label: t('Databases, caches, queues'), hint: 'idem db' }, + { value: 'workspaces', label: t('Workspaces'), hint: 'idem workspaces' }, + { value: 'servers', label: t('My servers'), hint: 'idem servers' }, + { value: 'dashboard', label: t('Open iDeploy'), hint: 'idem dashboard' }, + { value: 'logout', label: t('Sign out'), hint: 'idem logout' }, + { value: 'quit', label: t('Quit') }, ]; const action = unlessCancelled( - await prompts.select({ message: linked ? `This folder deploys to ${here}` : 'What do you want to do?', options }) + await prompts.select({ message: linked ? t('This folder deploys to {name}', { name: here }) : t('What do you want to do?'), options }) ) as Action; switch (action) { @@ -82,6 +90,6 @@ export async function menu(): Promise { case 'logout': return logout(); case 'quit': - prompts.outro('See you.'); + prompts.outro(t('See you.')); } } diff --git a/src/commands/rollback.ts b/src/commands/rollback.ts index e3dab2c..92dc091 100644 --- a/src/commands/rollback.ts +++ b/src/commands/rollback.ts @@ -2,6 +2,7 @@ import { api } from '../api.js'; import { followDeployment, resolveApp } from '../shared.js'; import { color, fail, interactive, printJson, prompts, unlessCancelled } from '../ui.js'; +import { t } from '../i18n.js'; interface Target { deploymentUuid: string; @@ -13,21 +14,21 @@ interface Target { export async function rollback(opts: { app?: string; to?: string; yes?: boolean; detach?: boolean }): Promise { const app = await resolveApp(opts.app); const targets = await api.get(`/applications/${app.uuid}/rollback-targets`); - if (targets.length < 2 && !opts.to) fail(`${app.name} has no earlier version to go back to.`); + if (targets.length < 2 && !opts.to) fail(t('{name} has no earlier version to go back to.', { name: app.name })); let chosen = opts.to ? targets.find((t) => t.deploymentUuid.startsWith(opts.to!)) : undefined; - if (opts.to && !chosen) fail(`No successful deployment "${opts.to}".`); + if (opts.to && !chosen) fail(t('No successful deployment "{id}".', { id: opts.to! })); if (!chosen) { if (!interactive() || opts.yes) { chosen = targets[1]; } else { const uuid = unlessCancelled( await prompts.select({ - message: 'Back to which version?', - options: targets.map((t, i) => ({ - value: t.deploymentUuid, - label: `${t.deploymentUuid.slice(0, 8)} ${t.commit === 'HEAD' ? '' : t.commit.slice(0, 7)}`, - hint: `${t.finishedAt ? new Date(t.finishedAt).toLocaleString() : ''}${i === 0 ? ' · current' : ''}`, + message: t('Back to which version?'), + options: targets.map((target, i) => ({ + value: target.deploymentUuid, + label: `${target.deploymentUuid.slice(0, 8)} ${target.commit === 'HEAD' ? '' : target.commit.slice(0, 7)}`, + hint: `${target.finishedAt ? new Date(target.finishedAt).toLocaleString() : ''}${i === 0 ? ` · ${t('current')}` : ''}`, })), initialValue: targets[1]?.deploymentUuid, }) @@ -38,11 +39,11 @@ export async function rollback(opts: { app?: string; to?: string; yes?: boolean; const started = await api.post<{ deploymentUuid: string }>(`/applications/${app.uuid}/rollback`, { deployment_uuid: chosen.deploymentUuid }); if (opts.detach) { - if (!printJson(started)) prompts.log.success(`Rollback queued (${started.deploymentUuid.slice(0, 8)}).`); + if (!printJson(started)) prompts.log.success(t('Rollback queued ({id}).', { id: started.deploymentUuid.slice(0, 8) })); return; } - prompts.log.step(`Rolling ${color.bold(app.name)} back to ${chosen.deploymentUuid.slice(0, 8)}`); + prompts.log.step(t('Rolling {name} back to {id}', { name: color.bold(app.name), id: chosen.deploymentUuid.slice(0, 8) })); const final = await followDeployment(started.deploymentUuid); - if (final.status !== 'finished') fail('The rollback failed — the cause is in the log above.'); - prompts.outro(`${color.green('Back')} on ${chosen.deploymentUuid.slice(0, 8)}${final.application_url ? ` · ${color.cyan(final.application_url)}` : ''}`); + if (final.status !== 'finished') fail(t('The rollback failed — the cause is in the log above.')); + prompts.outro(color.green(t('Back on {id}', { id: chosen.deploymentUuid.slice(0, 8) })) + (final.application_url ? ` · ${color.cyan(final.application_url)}` : '')); } diff --git a/src/commands/servers.ts b/src/commands/servers.ts index 817e2b6..5c6e9a1 100644 --- a/src/commands/servers.ts +++ b/src/commands/servers.ts @@ -9,6 +9,7 @@ import { join } from 'node:path'; import { api } from '../api.js'; import { confirmOrStop, pick, requireSignedIn, table } from '../shared.js'; import { color, fail, interactive, printJson, prompts, unlessCancelled } from '../ui.js'; +import { t } from '../i18n.js'; export interface Server { id: number; @@ -43,8 +44,8 @@ export async function serversList(): Promise { requireSignedIn(); const servers = await listServers(); if (printJson(servers.map((s) => ({ uuid: s.uuid, name: s.name, ip: s.ip })))) return; - if (servers.length === 0) return void prompts.log.info(`No server of your own. Add one: ${color.cyan('idem servers add')} — or deploy on IDEM's infrastructure, nothing to add.`); - process.stdout.write(table([[color.dim('NAME'), color.dim('ADDRESS'), color.dim('ID')], ...servers.map((s) => [s.name, `${s.user ?? 'root'}@${s.ip}:${s.port ?? 22}`, color.dim(s.uuid.slice(0, 8))])]) + '\n'); + if (servers.length === 0) return void prompts.log.info(t("No server of your own. Add one: {command} — or deploy on IDEM's infrastructure, nothing to add.", { command: color.cyan('idem servers add') })); + process.stdout.write(table([[color.dim(t('NAME')), color.dim(t('ADDRESS')), color.dim(t('ID'))], ...servers.map((s) => [s.name, `${s.user ?? 'root'}@${s.ip}:${s.port ?? 22}`, color.dim(s.uuid.slice(0, 8))])]) + '\n'); } function printReadiness(r: Readiness): void { @@ -59,9 +60,9 @@ function printReadiness(r: Readiness): void { export async function serversAdd(opts: { name?: string; ip?: string; port?: string; user?: string; key?: string; setup?: boolean }): Promise { requireSignedIn(); const ask = interactive(); - const ip = opts.ip ?? (ask ? unlessCancelled(await prompts.text({ message: 'Server address (IP or host name)', validate: (v) => (v.trim() ? undefined : 'Required.') })) : undefined); - if (!ip) fail('Give the address: --ip
.'); - const name = opts.name ?? (ask ? unlessCancelled(await prompts.text({ message: 'Name', initialValue: ip.replace(/[^a-z0-9-]+/gi, '-') })) : ip.replace(/[^a-z0-9-]+/gi, '-')); + const ip = opts.ip ?? (ask ? unlessCancelled(await prompts.text({ message: t('Server address (IP or host name)'), validate: (v) => (v.trim() ? undefined : t('Required.')) })) : undefined); + if (!ip) fail(t('Give the address: --ip
.')); + const name = opts.name ?? (ask ? unlessCancelled(await prompts.text({ message: t('Name'), initialValue: ip.replace(/[^a-z0-9-]+/gi, '-') })) : ip.replace(/[^a-z0-9-]+/gi, '-')); const user = opts.user ?? 'root'; const port = Number(opts.port ?? 22); @@ -72,24 +73,24 @@ export async function serversAdd(opts: { name?: string; ip?: string; port?: stri if (!keyPath && ask) { const choice = unlessCancelled( await prompts.select({ - message: 'SSH key iDeploy uses to reach it', + message: t('SSH key iDeploy uses to reach it'), options: [ - { value: 'generate', label: 'Generate a new key', hint: 'you add its public half to the server' }, - ...['id_ed25519', 'id_rsa'].filter((f) => existsSync(join(homedir(), '.ssh', f))).map((f) => ({ value: join(homedir(), '.ssh', f), label: `Use ~/.ssh/${f}` })), - { value: 'path', label: 'Another private key file' }, + { value: 'generate', label: t('Generate a new key'), hint: t('you add its public half to the server') }, + ...['id_ed25519', 'id_rsa'].filter((f) => existsSync(join(homedir(), '.ssh', f))).map((f) => ({ value: join(homedir(), '.ssh', f), label: t('Use {file}', { file: `~/.ssh/${f}` }) })), + { value: 'path', label: t('Another private key file') }, ], }) ); - keyPath = choice === 'generate' ? undefined : choice === 'path' ? unlessCancelled(await prompts.text({ message: 'Path to the private key' })) : String(choice); + keyPath = choice === 'generate' ? undefined : choice === 'path' ? unlessCancelled(await prompts.text({ message: t('Path to the private key') })) : String(choice); } if (keyPath) { let pem: string; try { pem = readFileSync(keyPath.replace(/^~/, homedir()), 'utf8'); } catch { - fail(`Cannot read ${keyPath}.`); + fail(t('Cannot read {file}.', { file: keyPath })); } - if (/ENCRYPTED/.test(pem)) fail('This key has a passphrase: iDeploy needs a key without one.', 'Generate one instead (leave --key out).'); + if (/ENCRYPTED/.test(pem)) fail(t('This key has a passphrase: iDeploy needs a key without one.'), t('Generate one instead (leave --key out).')); const key = await api.post('/security/keys', { name: `${name} (${keyPath.split('/').pop()})`, private_key: pem }); keyId = key.id; } else { @@ -100,13 +101,13 @@ export async function serversAdd(opts: { name?: string; ip?: string; port?: stri const server = await api.post('/servers', { name, ip, port, user, private_key_id: keyId }); if (printJson({ uuid: server.uuid, name: server.name, publicKey: installHint })) return; - prompts.log.success(`Server ${color.bold(server.name)} added.`); + prompts.log.success(t('Server {name} added.', { name: color.bold(server.name) })); if (installHint) { prompts.note( - `${color.dim(`On ${ip}, as ${user}, run:`)}\n\nmkdir -p ~/.ssh && echo '${installHint.trim()}' >> ~/.ssh/authorized_keys`, - 'Install the public key' + `${color.dim(t('On {ip}, as {user}, run:', { ip, user }))}\n\nmkdir -p ~/.ssh && echo '${installHint.trim()}' >> ~/.ssh/authorized_keys`, + t('Install the public key') ); - if (ask) unlessCancelled(await prompts.confirm({ message: 'Done? (iDeploy checks the server next)', initialValue: true })); + if (ask) unlessCancelled(await prompts.confirm({ message: t('Done? (iDeploy checks the server next)'), initialValue: true })); } await serversCheck(server.uuid, { setup: opts.setup ?? true }); } @@ -115,37 +116,37 @@ export async function serversAdd(opts: { name?: string; ip?: string; port?: stri export async function serversCheck(wanted: string | undefined, opts: { setup?: boolean } = {}): Promise { const server = await serverOf(wanted); const spin = prompts.spinner(); - spin.start(`Checking ${server.name}`); + spin.start(t('Checking {name}', { name: server.name })); const r = await api.post(`/servers/${server.uuid}/validate`); - spin.stop(r.ready ? `${server.name} is ready to deploy.` : `${server.name} is not ready yet.`, r.ready ? 0 : 1); + spin.stop(r.ready ? t('{name} is ready to deploy.', { name: server.name }) : t('{name} is not ready yet.', { name: server.name }), r.ready ? 0 : 1); if (printJson(r)) return; printReadiness(r); if (r.ready) return; - const setup = opts.setup && (!interactive() || unlessCancelled(await prompts.confirm({ message: 'Install Docker and the proxy on it now? (a few minutes)', initialValue: true }))); + const setup = opts.setup && (!interactive() || unlessCancelled(await prompts.confirm({ message: t('Install Docker and the proxy on it now? (a few minutes)'), initialValue: true }))); if (setup) await serversSetup(server.uuid); - else prompts.log.info(`When it is reachable: ${color.cyan(`idem servers setup ${server.name}`)}`); + else prompts.log.info(t('When it is reachable: {command}', { command: color.cyan(`idem servers setup ${server.name}`) })); } export async function serversSetup(wanted?: string): Promise { const server = await serverOf(wanted); const spin = prompts.spinner(); - spin.start(`Installing Docker and the proxy on ${server.name} (a few minutes)`); + spin.start(t('Installing Docker and the proxy on {name} (a few minutes)', { name: server.name })); try { const r = await api.post<{ success: boolean; output?: string; readiness?: Readiness }>(`/servers/${server.uuid}/setup`); - spin.stop(r.success ? `${server.name} is set up.` : `The setup of ${server.name} failed.`, r.success ? 0 : 1); + spin.stop(r.success ? t('{name} is set up.', { name: server.name }) : t('The setup of {name} failed.', { name: server.name }), r.success ? 0 : 1); if (!r.success) { if (r.output) process.stdout.write(color.dim(r.output.split('\n').slice(-25).join('\n')) + '\n'); - fail('Setup failed — the end of its output is above.'); + fail(t('Setup failed — the end of its output is above.')); } } catch (err) { - spin.stop('No answer yet.', 1); - fail(`${(err as Error).message}`, `A long install can outlast the request: check it with ${color.cyan(`idem servers check ${server.name}`)}.`); + spin.stop(t('No answer yet.'), 1); + fail(`${(err as Error).message}`, t('A long install can outlast the request: check it with {command}.', { command: color.cyan(`idem servers check ${server.name}`) })); } } export async function serversRemove(wanted: string | undefined, opts: { yes?: boolean }): Promise { const server = await serverOf(wanted); - await confirmOrStop(`Remove ${server.name} from iDeploy? What runs on it keeps running.`, opts.yes); + await confirmOrStop(t('Remove {name} from iDeploy? What runs on it keeps running.', { name: server.name }), opts.yes); await api.delete(`/servers/${server.uuid}`); - if (!printJson({ removed: server.uuid })) prompts.log.success(`${server.name} removed.`); + if (!printJson({ removed: server.uuid })) prompts.log.success(t('{name} removed.', { name: server.name })); } diff --git a/src/commands/services.ts b/src/commands/services.ts index 39216cc..f435a6f 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -5,6 +5,7 @@ import { api } from '../api.js'; import { confirmOrStop, pick, requireSignedIn, table } from '../shared.js'; import { color, fail, printJson, prompts } from '../ui.js'; +import { t } from '../i18n.js'; interface Service { uuid: string; @@ -31,27 +32,27 @@ export async function servicesList(): Promise { requireSignedIn(); const list = await listServices(); if (printJson(list)) return; - if (list.length === 0) return void prompts.log.info(`No service yet. From the catalogue: ${color.cyan('idem templates')}, then ${color.cyan('idem deploy --template ')}.`); - process.stdout.write(table([[color.dim('NAME'), color.dim('FROM'), color.dim('ID')], ...list.map((s) => [s.name, s.service_type ?? 'docker-compose.yml', color.dim(s.uuid.slice(0, 8))])]) + '\n'); + if (list.length === 0) return void prompts.log.info(t('No service yet. From the catalogue: {a}, then {b}.', { a: color.cyan('idem templates'), b: color.cyan('idem deploy --template ') })); + process.stdout.write(table([[color.dim(t('NAME')), color.dim(t('FROM')), color.dim(t('ID'))], ...list.map((s) => [s.name, s.service_type ?? 'docker-compose.yml', color.dim(s.uuid.slice(0, 8))])]) + '\n'); } export async function servicesAction(action: 'start' | 'stop' | 'restart', wanted?: string): Promise { const svc = await serviceOf(wanted); const spin = prompts.spinner(); - spin.start(`${action === 'start' ? 'Starting' : action === 'stop' ? 'Stopping' : 'Restarting'} ${svc.name}`); + spin.start(t(action === 'start' ? 'Starting {name}' : action === 'stop' ? 'Stopping {name}' : 'Restarting {name}', { name: svc.name })); const r = await api.post<{ success?: boolean; output?: string }>(`/services/${svc.uuid}/${action}`); if (r.success === false) { - spin.stop(`${svc.name}: ${action} failed.`, 1); + spin.stop(t('{name}: {action} failed.', { name: svc.name, action }), 1); fail(r.output?.split('\n').slice(-8).join('\n') || `${action} failed.`); } - spin.stop(`${svc.name}: done.`); + spin.stop(t('{name}: done.', { name: svc.name })); } export async function servicesRemove(wanted: string | undefined, opts: { yes?: boolean }): Promise { const svc = await serviceOf(wanted); - await confirmOrStop(`Delete ${svc.name}, its containers and its volumes?`, opts.yes); + await confirmOrStop(t('Delete {name}, its containers and its volumes?', { name: svc.name }), opts.yes); await api.delete(`/services/${svc.uuid}`); - if (!printJson({ removed: svc.uuid })) prompts.log.success(`${svc.name} deleted.`); + if (!printJson({ removed: svc.uuid })) prompts.log.success(t('{name} deleted.', { name: svc.name })); } export async function templatesList(search: string | undefined): Promise { @@ -60,7 +61,7 @@ export async function templatesList(search: string | undefined): Promise { const q = search?.toLowerCase(); const list = q ? all.filter((t) => `${t.name} ${t.slogan} ${t.category} ${t.tags.join(' ')}`.toLowerCase().includes(q)) : all; if (printJson(list.map((t) => ({ name: t.name, category: t.category, slogan: t.slogan })))) return; - if (list.length === 0) return void prompts.log.info(`No template matches "${search}".`); + if (list.length === 0) return void prompts.log.info(t('No template matches "{search}".', { search: search ?? '' })); process.stdout.write(table(list.map((t) => [color.bold(t.name), color.dim(t.category), t.slogan.slice(0, 70)])) + '\n'); - prompts.log.info(`${list.length} templates. Deploy one: ${color.cyan('idem deploy --template ')}`); + prompts.log.info(t('{count} templates. Deploy one: {command}', { count: list.length, command: color.cyan('idem deploy --template ') })); } diff --git a/src/commands/workspaces.ts b/src/commands/workspaces.ts index 650ed45..f8e30db 100644 --- a/src/commands/workspaces.ts +++ b/src/commands/workspaces.ts @@ -8,6 +8,7 @@ import { api } from '../api.js'; import { confirmOrStop, pick, requireSignedIn, table } from '../shared.js'; import { color, fail, interactive, printJson, prompts, unlessCancelled } from '../ui.js'; import { listServers, Server } from './servers.js'; +import { t } from '../i18n.js'; export interface Workspace { uuid: string; @@ -41,15 +42,15 @@ export async function workspaceOf(wanted?: string): Promise { } function where(w: Workspace): string { - return w.deploymentType === 'own' ? `your server ${w.assignedServerName ?? ''}`.trim() : `IDEM${w.region ? ` · ${w.region}` : ''}`; + return w.deploymentType === 'own' ? t('your server {name}', { name: w.assignedServerName ?? '' }).trim() : `IDEM${w.region ? ` · ${w.region}` : ''}`; } export async function workspacesList(): Promise { requireSignedIn(); const list = await listWorkspaces(); if (printJson(list)) return; - if (list.length === 0) return void prompts.log.info(`No workspace yet. ${color.cyan('idem deploy')} creates one, or ${color.cyan('idem workspaces create')}.`); - process.stdout.write(table([[color.dim('NAME'), color.dim('RUNS ON'), color.dim('ID')], ...list.map((w) => [w.name, where(w), color.dim(w.uuid.slice(0, 8))])]) + '\n'); + if (list.length === 0) return void prompts.log.info(t('No workspace yet. {a} creates one, or {b}.', { a: color.cyan('idem deploy'), b: color.cyan('idem workspaces create') })); + process.stdout.write(table([[color.dim(t('NAME')), color.dim(t('RUNS ON')), color.dim(t('ID'))], ...list.map((w) => [w.name, where(w), color.dim(w.uuid.slice(0, 8))])]) + '\n'); } /** @@ -66,15 +67,15 @@ export async function placement(opts: { server?: string; region?: string }): Pro const servers: Server[] = await listServers().catch(() => []); const choice = unlessCancelled( await prompts.select({ - message: 'Where should it run?', + message: t('Where should it run?'), options: [ - { value: 'saas', label: "IDEM's infrastructure", hint: 'nothing to manage' }, - ...servers.map((s) => ({ value: s.uuid, label: `Your server ${s.name}`, hint: s.ip })), - ...(servers.length === 0 ? [{ value: 'add', label: 'One of your servers…', hint: 'add it first: idem servers add' }] : []), + { value: 'saas', label: t("IDEM's infrastructure"), hint: t('nothing to manage') }, + ...servers.map((s) => ({ value: s.uuid, label: t('Your server {name}', { name: s.name }), hint: s.ip })), + ...(servers.length === 0 ? [{ value: 'add', label: t('One of your servers…'), hint: t('add it first: idem servers add') }] : []), ], }) ); - if (choice === 'add') fail('Add your server first.', `${color.cyan('idem servers add')}, then run this again.`); + if (choice === 'add') fail(t('Add your server first.'), t('{command}, then run this again.', { command: color.cyan('idem servers add') })); if (choice !== 'saas') return { deployment_type: 'own', server_uuid: String(choice) }; const options = await api.get('/workspaces/options').catch(() => null); @@ -82,8 +83,8 @@ export async function placement(opts: { server?: string; region?: string }): Pro if (!options?.regionSelectionAllowed || regions.length < 2) return { deployment_type: 'saas' }; const region = unlessCancelled( await prompts.select({ - message: 'Region', - options: [{ value: '', label: 'Automatic', hint: 'the nearest with room' }, ...regions.map((r) => ({ value: r.code, label: r.name ?? r.code }))], + message: t('Region'), + options: [{ value: '', label: t('Automatic'), hint: t('the nearest with room') }, ...regions.map((r) => ({ value: r.code, label: r.name ?? r.code }))], }) ); return { deployment_type: 'saas', ...(region ? { region: String(region) } : {}) }; @@ -91,11 +92,11 @@ export async function placement(opts: { server?: string; region?: string }): Pro export async function workspacesCreate(name: string | undefined, opts: { server?: string; region?: string; description?: string }): Promise { requireSignedIn(); - const wsName = name ?? (interactive() ? unlessCancelled(await prompts.text({ message: 'Workspace name', validate: (v) => (v.trim() ? undefined : 'Required.') })) : undefined); - if (!wsName) fail('Give a name: idem workspaces create .'); + const wsName = name ?? (interactive() ? unlessCancelled(await prompts.text({ message: t('Workspace name'), validate: (v) => (v.trim() ? undefined : t('Required.')) })) : undefined); + if (!wsName) fail(t('Give a name: idem workspaces create .')); const where_ = await placement(opts); const created = await api.post('/workspaces', { name: wsName, description: opts.description, ...where_ }); - if (!printJson(created)) prompts.log.success(`Workspace ${color.bold(created.name)} created (${where(created)}).`); + if (!printJson(created)) prompts.log.success(t('Workspace {name} created ({where}).', { name: color.bold(created.name), where: where(created) })); } export async function workspacesShow(wanted?: string): Promise { @@ -104,11 +105,11 @@ export async function workspacesShow(wanted?: string): Promise { if (printJson({ ...ws, resources })) return; prompts.note( [ - `${color.dim('Runs on')} ${where(ws)}`, + `${color.dim(t('Runs on'))} ${where(ws)}`, '', ...(resources.length ? resources.map((r) => `${r.name} ${color.dim(`${r.databaseType ?? r.kind}${r.status ? ` · ${r.status}` : ''} · ${r.internalHost}`)}`) - : [color.dim('Empty.')]), + : [color.dim(t('Empty.'))]), ].join('\n'), ws.name ); @@ -116,7 +117,7 @@ export async function workspacesShow(wanted?: string): Promise { export async function workspacesRemove(wanted: string | undefined, opts: { yes?: boolean }): Promise { const ws = await workspaceOf(wanted); - await confirmOrStop(`Delete workspace ${ws.name}? It must be empty.`, opts.yes); + await confirmOrStop(t('Delete workspace {name}? It must be empty.', { name: ws.name }), opts.yes); await api.delete(`/workspaces/${ws.uuid}`); - if (!printJson({ removed: ws.uuid })) prompts.log.success(`${ws.name} deleted.`); + if (!printJson({ removed: ws.uuid })) prompts.log.success(t('{name} deleted.', { name: ws.name })); } diff --git a/src/completion.ts b/src/completion.ts new file mode 100644 index 0000000..2e97c58 --- /dev/null +++ b/src/completion.ts @@ -0,0 +1,78 @@ +/** + * `idem completion bash|zsh|fish` — tab completion of the commands, their + * sub-commands and options, generated from the CLI itself so it never lags. + * + * bash: idem completion bash >> ~/.bashrc + * zsh: idem completion zsh >> ~/.zshrc + * fish: idem completion fish > ~/.config/fish/completions/idem.fish + */ +import type { Command } from 'commander'; +import { fail } from './ui.js'; +import { t } from './i18n.js'; + +interface Tree { + name: string; + description: string; + options: string[]; + subcommands: Tree[]; +} + +function treeOf(cmd: Command): Tree { + return { + name: cmd.name(), + description: cmd.description().replace(/'/g, ''), + options: cmd.options.map((o) => o.long ?? o.short ?? '').filter(Boolean), + subcommands: cmd.commands.map(treeOf), + }; +} + +function bash(root: Tree): string { + const top = root.subcommands.map((c) => c.name).join(' '); + const cases = root.subcommands + .map((c) => { + const subs = c.subcommands.map((s) => s.name).join(' '); + const opts = [...c.options, ...c.subcommands.flatMap((s) => s.options)].join(' '); + return ` ${c.name}) subs="${subs}"; opts="${opts} --help --json";;`; + }) + .join('\n'); + return `# idem completion (bash; zsh through bashcompinit) +_idem() { + local cur=\${COMP_WORDS[COMP_CWORD]} subs="" opts="" + if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W "${top} --help --version --json --lang" -- "$cur")); return; fi + case "\${COMP_WORDS[1]}" in +${cases} + esac + if [ "$COMP_CWORD" -eq 2 ] && [ -n "$subs" ] && [[ "$cur" != -* ]]; then COMPREPLY=($(compgen -W "$subs" -- "$cur")); return; fi + COMPREPLY=($(compgen -W "$opts" -- "$cur")) +} +complete -o default -F _idem idem +`; +} + +function zsh(root: Tree): string { + return `# idem completion (zsh) +autoload -U +X bashcompinit && bashcompinit +${bash(root)}`; +} + +function fish(root: Tree): string { + const lines = ['# idem completion (fish)', 'complete -c idem -f']; + for (const c of root.subcommands) { + lines.push(`complete -c idem -n '__fish_use_subcommand' -a '${c.name}' -d '${c.description}'`); + for (const s of c.subcommands) { + lines.push(`complete -c idem -n '__fish_seen_subcommand_from ${c.name}' -a '${s.name}' -d '${s.description}'`); + } + for (const o of [...c.options, ...c.subcommands.flatMap((s) => s.options)]) { + if (o.startsWith('--')) lines.push(`complete -c idem -n '__fish_seen_subcommand_from ${c.name}' -l '${o.slice(2)}'`); + } + } + return lines.join('\n') + '\n'; +} + +export function completion(program: Command, shell: string | undefined): void { + const root = treeOf(program); + const scripts: Record string> = { bash, zsh, fish }; + const make = scripts[(shell ?? '').toLowerCase()]; + if (!make) fail(t('Which shell? bash, zsh or fish.'), t('Example: idem completion bash >> ~/.bashrc')); + process.stdout.write(make(root)); +} diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 0000000..ee17332 --- /dev/null +++ b/src/help.ts @@ -0,0 +1,65 @@ +/** + * The help a developer reads first: commands grouped by what they are for, + * the few examples that cover most needs, and where to read more. Each + * command's own help (`idem deploy --help`) ends with its examples. + */ +import type { Command } from 'commander'; +import { color } from './ui.js'; +import { t } from './i18n.js'; + +export const DOCS_URL = 'https://github.com/Idem-AI/cli#readme'; + +const GROUPS: { title: string; commands: string[] }[] = [ + { title: 'Deploy', commands: ['deploy', 'templates'] }, + { title: 'Application', commands: ['apps', 'status', 'logs', 'deployments', 'open', 'env', 'domains', 'restart', 'start', 'stop', 'rollback', 'destroy', 'link', 'unlink'] }, + { title: 'Infrastructure', commands: ['workspaces', 'db', 'services', 'servers'] }, + { title: 'Account', commands: ['login', 'logout', 'whoami', 'dashboard', 'completion'] }, +]; + +export function rootHelp(program: Command, version: string): string { + const byName = new Map(program.commands.map((c) => [c.name(), c])); + const width = 14; + const lines: string[] = [ + '', + `${color.bgCyan(color.black(' idem '))} ${color.bold(t('Deploy to iDeploy from your terminal'))} ${color.dim(`v${version}`)}`, + '', + ` ${t('Start here, in your project folder:')} ${color.cyan(color.bold('idem deploy'))}`, + ` ${color.dim(t('Not sure? Run idem alone: a menu shows what you can do.'))}`, + ]; + for (const group of GROUPS) { + lines.push('', color.bold(t(group.title))); + for (const name of group.commands) { + const cmd = byName.get(name); + if (!cmd) continue; + lines.push(` ${color.cyan(name.padEnd(width))} ${cmd.description()}`); + } + } + lines.push( + '', + color.bold(t('Examples')), + example('idem deploy', 'this folder — creates the application the first time'), + example('idem deploy --git https://github.com/me/app', 'a Git repository'), + example('idem deploy --template n8n', 'a one-click service'), + example('idem logs -f', 'what the application prints, live'), + example('idem db create postgresql', 'a database, linked at the next deploy'), + '', + color.bold(t('Options')), + ` ${color.cyan('--json'.padEnd(width))} ${t('machine-readable output (scripts, CI)')}`, + ` ${color.cyan('--lang fr|en'.padEnd(width))} ${t('language (default: your system’s)')}`, + ` ${color.cyan('-v, --version'.padEnd(width))} ${t('the version')}`, + '', + `${t('Every command has its own help:')} ${color.cyan('idem --help')}`, + `${t('Documentation:')} ${color.cyan(DOCS_URL)}`, + '' + ); + return lines.join('\n'); +} + +function example(command: string, what: string): string { + return ` ${color.cyan(command)}\n ${color.dim(t(what))}`; +} + +/** "Examples" at the end of a command's help. */ +export function withExamples(cmd: Command, examples: [string, string][]): Command { + return cmd.addHelpText('after', () => `\n${color.bold(t('Examples'))}\n${examples.map(([c, w]) => example(c, w)).join('\n')}\n`); +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..b63799b --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,43 @@ +/** + * The CLI speaks English or French: IDEM_LANG=fr|en, else the system's + * language (LC_ALL, LC_MESSAGES, LANG, then the runtime's locale). + * + * `t()` takes the English text — the key — with `{name}` placeholders, and + * returns it in the chosen language. A text missing from a dictionary stays + * in English rather than showing a key. + */ +import { fr } from './locales/fr.js'; + +export type Lang = 'en' | 'fr'; + +export function detectLang(env: NodeJS.ProcessEnv = process.env): Lang { + const explicit = env.IDEM_LANG?.toLowerCase(); + if (explicit === 'fr' || explicit === 'en') return explicit; + const system = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE || safeLocale(); + return /^fr\b|^fr[_-]/i.test(system ?? '') ? 'fr' : 'en'; +} + +function safeLocale(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().locale; + } catch { + return ''; + } +} + +let lang: Lang = detectLang(); + +export function setLang(value: Lang): void { + lang = value; +} + +export function currentLang(): Lang { + return lang; +} + +const dictionaries: Record> = { en: {}, fr }; + +export function t(text: string, vars: Record = {}): string { + const template = dictionaries[lang][text] ?? text; + return template.replace(/\{(\w+)\}/g, (whole, name: string) => (name in vars ? String(vars[name]) : whole)); +} diff --git a/src/index.ts b/src/index.ts index cc9981e..9a3dbce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,10 @@ */ import { Command } from 'commander'; import { createRequire } from 'node:module'; -import { run, setJson, interactive } from './ui.js'; +import { fail, run, setJson, interactive } from './ui.js'; +import { setLang, t } from './i18n.js'; +import { rootHelp, withExamples } from './help.js'; +import { completion } from './completion.js'; import { login, logout, whoami } from './commands/auth.js'; import { deploy } from './commands/deploy.js'; import { apps, dashboard, deployments, destroy, domainsAdd, domainsList, domainsRemove, link, open, restart, startStop, status, unlink } from './commands/apps.js'; @@ -21,134 +24,251 @@ import { menu } from './commands/menu.js'; const { version } = createRequire(import.meta.url)('../package.json') as { version: string }; +// The language is known before the commands are described: their help is translated too. +const langAt = process.argv.findIndex((a) => a === '--lang' || a.startsWith('--lang=')); +if (langAt >= 0) { + const value = (process.argv[langAt].includes('=') ? process.argv[langAt].split('=')[1] : process.argv[langAt + 1])?.toLowerCase(); + if (value === 'fr' || value === 'en') setLang(value); +} + const program = new Command(); program .name('idem') - .description('Deploy to iDeploy from your terminal. Start with: idem deploy') - .version(version, '-v, --version') - .option('--json', 'machine-readable output (scripts, CI)') + .description(t('Deploy to iDeploy from your terminal. Start with: idem deploy')) + .version(version, '-v, --version', t('the version')) + .option('--json', t('machine-readable output (scripts, CI)')) + .option('--lang ', t('language: fr or en')) + .helpOption('-h, --help', t('this help')) + .addHelpCommand(false) + .showSuggestionAfterError(true) .hook('preAction', (_program, action) => setJson(Boolean(action.optsWithGlobals().json))) .action( run(async () => { + // `idem deplo`: a mistyped command, not a reason to open the menu. + const [typed] = program.args; + if (typed) { + const near = closest(typed, program.commands.flatMap((c) => [c.name(), ...c.aliases()])); + fail(t('Unknown command "{name}".', { name: typed }), near ? t('Did you mean {command}?', { command: `idem ${near}` }) : t('Every command: idem --help')); + } if (!interactive()) return void program.help(); await menu(); }) ); +// The first screen: grouped, with examples — not the list a parser prints. +program.helpInformation = () => rootHelp(program, version); -program - .command('deploy') - .description('Deploy — this folder by default; creates the application the first time') - .option('-a, --app ', 'deploy to this application (name or id)') - .option('--git ', 'deploy a Git repository instead of this folder') - .option('--branch ', 'its branch (default: the default branch)') - .option('--image ', 'run an image from a registry (registry/org/app:tag)') - .option('--tag ', 'the image tag to deploy') - .option('--registry-user ', 'private registry login (password in IDEM_REGISTRY_PASSWORD)') - .option('--compose ', 'deploy a docker-compose.yml as a service') - .option('--template ', 'deploy a service of the catalogue (see idem templates)') - .option('-w, --workspace ', 'workspace of a new application (created if needed)') - .option('--server ', 'a new workspace runs on this server of yours') - .option('--region ', "a new workspace runs on IDEM's infrastructure in this region") - .option('-n, --name ', 'name of a new application (default: the folder name)') - .option('--base-dir ', 'the application folder inside a monorepo') - .option('--install ', 'install command') - .option('--build ', 'build command') - .option('--start ', 'start command') - .option('--port ', 'the port the application listens on') - .option('--publish-dir ', 'the built folder of a static site') - .option('--env [file]', 'send the variables of .env (or of this file) with a new application') - .option('--upload', 'send this folder even to an application that deploys from Git or an image') - .option('-y, --yes', 'ask nothing: defaults everywhere (CI)') - .option('-d, --detach', 'queue the deployment and return without following it') - .action(run(deploy)); - -program.command('login').description('Sign in through the browser').option('--token ', 'sign in with an API token instead (CI)').option('--api ', 'another iDeploy instance').action(run(login)); -program.command('logout').description('Sign out and revoke this machine\'s token').action(run(logout)); -program.command('whoami').description('Who you are signed in as').action(run(whoami)); - -program.command('apps').alias('ls').description('Your applications').action(run(apps)); -program.command('link').description('Link this folder to an existing application').option('-a, --app ', 'the application').action(run(link)); -program.command('unlink').description('Forget which application this folder deploys to').action(run(unlink)); -program.command('status').description('Status of the application and its last deployment').option('-a, --app ').action(run(status)); -program - .command('logs') - .description('What the running application prints') - .option('-a, --app ') - .option('-f, --follow', 'keep printing new lines') - .option('-n, --lines ', 'how many of the last lines', '200') - .action(run(logs)); -program.command('open').description('Open the application in the browser').option('-a, --app ').option('--dashboard', 'open its page on iDeploy instead').action(run(open)); -program.command('dashboard').description('Open iDeploy in the browser').action(run(dashboard)); -program.command('start').description('Start a stopped application').option('-a, --app ').action(run((o: { app?: string }) => startStop('start', o))); -program.command('stop').description('Stop the application').option('-a, --app ').option('-y, --yes').action(run((o: { app?: string; yes?: boolean }) => startStop('stop', o))); -program.command('destroy').description('Delete the application, its data and its address').option('-a, --app ').option('-y, --yes').action(run(destroy)); -program.command('deployments').description('Deployment history').option('-a, --app ').action(run(deployments)); -program.command('restart').description('Restart the application').option('-a, --app ').option('-y, --yes', 'do not ask').action(run(restart)); -program - .command('rollback') - .description('Go back to an earlier version') - .option('-a, --app ') - .option('--to ', 'the deployment to go back to (id, or its first characters)') - .option('-y, --yes', 'the previous version, without asking') - .option('-d, --detach', 'do not follow it') - .action(run(rollback)); - -const env = program.command('env').description('Variables of the application'); -env.command('ls', { isDefault: true }).description('List them (values hidden)').option('-a, --app ').option('--reveal', 'show the values').action(run(envList)); -env.command('set').description('Set KEY=value…').argument('').option('-a, --app ').action(run(envSet)); -env.command('rm').description('Remove variables').argument('').option('-a, --app ').action(run(envRemove)); -env.command('pull').description('Write them to a local .env').argument('[file]').option('-a, --app ').option('-y, --yes', 'replace an existing file').action(run(envPull)); - -const domains = program.command('domains').description('Addresses of the application'); -domains.command('ls', { isDefault: true }).description('Its addresses, with DNS and HTTPS state').option('-a, --app ').action(run(domainsList)); -domains.command('add').description('Add your domain (it becomes the main address)').argument('').option('-a, --app ').action(run(domainsAdd)); -domains.command('rm').description('Remove an address').argument('').option('-a, --app ').action(run(domainsRemove)); - -const servers = program.command('servers').description('Your own servers (IDEM-managed ones need nothing)'); -servers.command('ls', { isDefault: true }).description('List them').action(run(serversList)); +const app = () => ['-a, --app ', t('the application (name or id; default: the one of this folder)')] as const; + +withExamples( + program + .command('deploy') + .description(t('Deploy — this folder by default; creates the application the first time')) + .option(...app()) + .option('--git ', t('deploy a Git repository instead of this folder')) + .option('--branch ', t('its branch (default: the default branch)')) + .option('--image ', t('run an image from a registry (registry/org/app:tag)')) + .option('--tag ', t('the image tag to deploy')) + .option('--registry-user ', t('private registry login (password in IDEM_REGISTRY_PASSWORD)')) + .option('--compose ', t('deploy a docker-compose.yml as a service')) + .option('--template ', t('deploy a service of the catalogue (see idem templates)')) + .option('-w, --workspace ', t('workspace of a new application (created if needed)')) + .option('--server ', t('a new workspace runs on this server of yours')) + .option('--region ', t("a new workspace runs on IDEM's infrastructure in this region")) + .option('-n, --name ', t('name of a new application (default: the folder name)')) + .option('--base-dir ', t('the application folder inside a monorepo')) + .option('--install ', t('install command')) + .option('--build ', t('build command')) + .option('--start ', t('start command')) + .option('--port ', t('the port the application listens on')) + .option('--publish-dir ', t('the built folder of a static site')) + .option('--env [file]', t('send the variables of .env (or of this file) with a new application')) + .option('--upload', t('send this folder even to an application that deploys from Git or an image')) + .option('-y, --yes', t('ask nothing: defaults everywhere (CI)')) + .option('-d, --detach', t('queue the deployment and return without following it')) + .action(run(deploy)), + [ + ['idem deploy', 'this folder — creates the application the first time'], + ['idem deploy --git https://github.com/me/shop --branch main', 'a Git repository'], + ['idem deploy --image ghcr.io/me/api:1.4 --port 8080', 'an image already built'], + ['idem deploy --compose docker-compose.yml', 'your own stack, as a service'], + ['idem deploy --server my-vps', 'a new application, on one of your servers'], + ['IDEM_TOKEN=… idem deploy --yes --app shop', 'in CI'], + ] +); + +withExamples( + program + .command('login') + .description(t('Sign in through the browser')) + .option('--token ', t('sign in with an API token instead (CI)')) + .option('--api ', t('another iDeploy instance')) + .action(run(login)), + [ + ['idem login', 'opens the browser; check the code, authorise'], + ['idem login --token 12|abc…', 'with a token from Security › API tokens'], + ] +); +program.command('logout').description(t("Sign out and revoke this machine's token")).action(run(logout)); +program.command('whoami').description(t('Who you are signed in as')).action(run(whoami)); + +program.command('apps').alias('ls').description(t('Your applications')).action(run(apps)); +program.command('link').description(t('Link this folder to an existing application')).option(...app()).action(run(link)); +program.command('unlink').description(t('Forget which application this folder deploys to')).action(run(unlink)); +program.command('status').description(t('Status of the application and its last deployment')).option(...app()).action(run(status)); +withExamples( + program + .command('logs') + .description(t('What the running application prints')) + .option(...app()) + .option('-f, --follow', t('keep printing new lines')) + .option('-n, --lines ', t('how many of the last lines'), '200') + .action(run(logs)), + [ + ['idem logs -f', 'follow it live'], + ['idem logs -n 1000 --app shop | grep ERROR', 'search the last 1000 lines'], + ] +); +program.command('open').description(t('Open the application in the browser')).option(...app()).option('--dashboard', t('open its page on iDeploy instead')).action(run(open)); +program.command('dashboard').description(t('Open iDeploy in the browser')).action(run(dashboard)); +program.command('start').description(t('Start a stopped application')).option(...app()).action(run((o: { app?: string }) => startStop('start', o))); +program.command('stop').description(t('Stop the application')).option(...app()).option('-y, --yes', t('do not ask')).action(run((o: { app?: string; yes?: boolean }) => startStop('stop', o))); +program.command('restart').description(t('Restart the application')).option(...app()).option('-y, --yes', t('do not ask')).action(run(restart)); +program.command('destroy').description(t('Delete the application, its data and its address')).option(...app()).option('-y, --yes', t('do not ask')).action(run(destroy)); +program.command('deployments').description(t('Deployment history')).option(...app()).action(run(deployments)); +withExamples( + program + .command('rollback') + .description(t('Go back to an earlier version')) + .option(...app()) + .option('--to ', t('the deployment to go back to (id, or its first characters)')) + .option('-y, --yes', t('the previous version, without asking')) + .option('-d, --detach', t('do not follow it')) + .action(run(rollback)), + [ + ['idem rollback', 'choose the version in a list'], + ['idem rollback --yes', 'straight back to the previous one'], + ] +); + +const env = withExamples(program.command('env').description(t('Variables of the application')), [ + ['idem env', 'list them, values hidden'], + ['idem env set APP_ENV=production MAIL_HOST=smtp.example.com', 'set some; they apply at the next deploy'], + ['idem env pull .env.local', 'write them to a local file'], +]); +env.command('ls', { isDefault: true }).description(t('List them (values hidden)')).option(...app()).option('--reveal', t('show the values')).action(run(envList)); +env.command('set').description(t('Set KEY=value…')).argument('').option(...app()).action(run(envSet)); +env.command('rm').description(t('Remove variables')).argument('').option(...app()).action(run(envRemove)); +env.command('pull').description(t('Write them to a local .env')).argument('[file]').option(...app()).option('-y, --yes', t('replace an existing file')).action(run(envPull)); + +const domains = withExamples(program.command('domains').description(t('Addresses of the application')), [ + ['idem domains', 'its addresses, with DNS and HTTPS state'], + ['idem domains add shop.example.com', 'your domain: the DNS record to create is shown'], +]); +domains.command('ls', { isDefault: true }).description(t('Its addresses, with DNS and HTTPS state')).option(...app()).action(run(domainsList)); +domains.command('add').description(t('Add your domain (it becomes the main address)')).argument('').option(...app()).action(run(domainsAdd)); +domains.command('rm').description(t('Remove an address')).argument('').option(...app()).action(run(domainsRemove)); + +const servers = withExamples(program.command('servers').description(t('Your own servers (IDEM-managed ones need nothing)')), [ + ['idem servers add', 'step by step: address, SSH key, checks, Docker'], + ['idem servers add --ip 203.0.113.10 --name my-vps --key ~/.ssh/id_ed25519', 'without questions'], + ['idem servers check my-vps', 'can iDeploy deploy there?'], +]); +servers.command('ls', { isDefault: true }).description(t('List them')).action(run(serversList)); servers .command('add') - .description('Add a server: SSH key, checks, Docker and proxy installed') - .option('--name ') - .option('--ip
') - .option('--port ', 'SSH port', '22') - .option('--user ', 'SSH user', 'root') - .option('--key ', 'a private key of yours (default: a new key is generated)') - .option('--no-setup', 'do not install Docker and the proxy') + .description(t('Add a server: SSH key, checks, Docker and proxy installed')) + .option('--name ', t('its name')) + .option('--ip
', t('its address')) + .option('--port ', t('SSH port'), '22') + .option('--user ', t('SSH user'), 'root') + .option('--key ', t('a private key of yours (default: a new key is generated)')) + .option('--no-setup', t('do not install Docker and the proxy')) .action(run(serversAdd)); -servers.command('check').description('Can iDeploy deploy there?').argument('[server]').action(run((s?: string) => serversCheck(s, { setup: true }))); -servers.command('setup').description('Install Docker and the proxy').argument('[server]').action(run(serversSetup)); -servers.command('rm').description('Remove a server from iDeploy').argument('[server]').option('-y, --yes').action(run(serversRemove)); +servers.command('check').description(t('Can iDeploy deploy there?')).argument('[server]').action(run((s?: string) => serversCheck(s, { setup: true }))); +servers.command('setup').description(t('Install Docker and the proxy')).argument('[server]').action(run(serversSetup)); +servers.command('rm').description(t('Remove a server from iDeploy')).argument('[server]').option('-y, --yes', t('do not ask')).action(run(serversRemove)); -const workspaces = program.command('workspaces').alias('ws').description('Workspaces: where applications and databases run together'); -workspaces.command('ls', { isDefault: true }).description('List them').action(run(workspacesList)); +const workspaces = withExamples( + program.command('workspaces').alias('ws').description(t('Workspaces: where applications and databases run together')), + [ + ['idem workspaces create shop', "on IDEM's infrastructure (asked on a terminal)"], + ['idem workspaces create shop --server my-vps', 'on your server'], + ['idem ws show shop', 'what runs in it, and how its parts reach each other'], + ] +); +workspaces.command('ls', { isDefault: true }).description(t('List them')).action(run(workspacesList)); workspaces .command('create') - .description("Create one — on IDEM's infrastructure or on your server") + .description(t("Create one — on IDEM's infrastructure or on your server")) .argument('[name]') - .option('--server ', 'on this server of yours') - .option('--region ', "on IDEM's infrastructure, in this region") - .option('--description ') + .option('--server ', t('on this server of yours')) + .option('--region ', t("on IDEM's infrastructure, in this region")) + .option('--description ', t('a description')) .action(run(workspacesCreate)); -workspaces.command('show').description('What runs in it, and how its parts reach each other').argument('[workspace]').action(run(workspacesShow)); -workspaces.command('rm').description('Delete an empty workspace').argument('[workspace]').option('-y, --yes').action(run(workspacesRemove)); +workspaces.command('show').description(t('What runs in it, and how its parts reach each other')).argument('[workspace]').action(run(workspacesShow)); +workspaces.command('rm').description(t('Delete an empty workspace')).argument('[workspace]').option('-y, --yes', t('do not ask')).action(run(workspacesRemove)); -const db = program.command('db').description(`Databases, caches and queues (${ENGINES.join(', ')})`); -db.command('ls', { isDefault: true }).description('List them').action(run(dbList)); -db.command('create').description('Create one in a workspace, and start it').argument('[engine]').option('--name ').option('-w, --workspace ').option('--no-start', 'only create it').action(run(dbCreate)); -db.command('show').description('Address and credentials').argument('[database]').option('--reveal', 'show the secrets').action(run(dbShow)); +const db = withExamples(program.command('db').description(t('Databases, caches and queues ({list})', { list: ENGINES.join(', ') })), [ + ['idem db create postgresql -w shop', 'a database in a workspace, started'], + ['idem db show shop-postgresql --reveal', 'its address and credentials'], + ['idem db backup shop-postgresql', 'a backup now'], +]); +db.command('ls', { isDefault: true }).description(t('List them')).action(run(dbList)); +db.command('create').description(t('Create one in a workspace, and start it')).argument('[engine]').option('--name ', t('its name')).option('-w, --workspace ', t('its workspace')).option('--no-start', t('only create it')).action(run(dbCreate)); +db.command('show').description(t('Address and credentials')).argument('[database]').option('--reveal', t('show the secrets')).action(run(dbShow)); for (const action of ['start', 'stop', 'restart'] as const) { - db.command(action).description(`${action[0].toUpperCase()}${action.slice(1)} it`).argument('[database]').action(run((d?: string) => dbAction(action, d))); + const text = { start: 'Start it', stop: 'Stop it', restart: 'Restart it' }[action]; + db.command(action).description(t(text)).argument('[database]').action(run((d?: string) => dbAction(action, d))); } -db.command('backup').description('Back it up now').argument('[database]').action(run(dbBackup)); -db.command('rm').description('Delete it and its data').argument('[database]').option('-y, --yes').action(run(dbRemove)); +db.command('backup').description(t('Back it up now')).argument('[database]').action(run(dbBackup)); +db.command('rm').description(t('Delete it and its data')).argument('[database]').option('-y, --yes', t('do not ask')).action(run(dbRemove)); -const services = program.command('services').description('Services: docker-compose stacks and catalogue templates'); -services.command('ls', { isDefault: true }).description('List them').action(run(servicesList)); +const services = program.command('services').description(t('Services: docker-compose stacks and catalogue templates')); +services.command('ls', { isDefault: true }).description(t('List them')).action(run(servicesList)); for (const action of ['start', 'stop', 'restart'] as const) { - services.command(action).description(`${action[0].toUpperCase()}${action.slice(1)} it`).argument('[service]').action(run((s?: string) => servicesAction(action, s))); + const text = { start: 'Start it', stop: 'Stop it', restart: 'Restart it' }[action]; + services.command(action).description(t(text)).argument('[service]').action(run((s?: string) => servicesAction(action, s))); +} +services.command('rm').description(t('Delete it, its containers and volumes')).argument('[service]').option('-y, --yes', t('do not ask')).action(run(servicesRemove)); +withExamples(program.command('templates').description(t('The catalogue of one-click services')).argument('[search]').action(run(templatesList)), [ + ['idem templates analytics', 'search the catalogue'], + ['idem deploy --template plausible', 'deploy one'], +]); + +withExamples( + program + .command('completion') + .description(t('Tab completion for your shell (bash, zsh, fish)')) + .argument('[shell]') + .action((shell?: string) => completion(program, shell)), + [ + ['idem completion bash >> ~/.bashrc', 'bash'], + ['idem completion zsh >> ~/.zshrc', 'zsh'], + ['idem completion fish > ~/.config/fish/completions/idem.fish', 'fish'], + ] +); + +/** The nearest command name, within two edits — else none. */ +function closest(word: string, names: string[]): string | null { + const distance = (a: string, b: string): number => { + const row = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + let previous = row[0]; + row[0] = i; + for (let j = 1; j <= b.length; j++) { + const current = row[j]; + row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1)); + previous = current; + } + } + return row[b.length]; + }; + let best: { name: string; d: number } | null = null; + for (const name of names) { + const d = distance(word.toLowerCase(), name); + if (d <= 2 && (!best || d < best.d)) best = { name, d }; + } + return best?.name ?? null; } -services.command('rm').description('Delete it, its containers and volumes').argument('[service]').option('-y, --yes').action(run(servicesRemove)); -program.command('templates').description('The catalogue of one-click services').argument('[search]').action(run(templatesList)); program.parseAsync(process.argv); diff --git a/src/locales/fr.ts b/src/locales/fr.ts new file mode 100644 index 0000000..1838890 --- /dev/null +++ b/src/locales/fr.ts @@ -0,0 +1,417 @@ +/** French. Keys are the English texts (see i18n.ts); a missing one stays in English. */ +export const fr: Record = { + 'No application yet. Run {command} in a project folder.': 'Aucune application pour l\'instant. Lancez {command} dans le dossier d\'un projet.', + 'this folder': 'ce dossier', + 'Which application?': 'Quelle application ?', + 'Pass `--app `.': 'Passez `--app `.', + 'You have no application yet.': 'Vous n\'avez encore aucune application.', + 'Run `idem deploy` here instead: it creates one.': 'Lancez plutôt `idem deploy` ici : il en crée une.', + 'Link this folder to': 'Lier ce dossier à', + 'Not linked.': 'Non lié.', + 'This folder now deploys to {name}.': 'Ce dossier déploie désormais vers {name}.', + 'This folder was not linked.': 'Ce dossier n\'était pas lié.', + 'Unlinked from {name}. The application itself is untouched.': 'Délié de {name}. L\'application elle-même n\'est pas touchée.', + 'unknown': 'inconnu', + 'none yet': 'aucun pour le moment', + 'Status': 'Statut', + '{name} has no address yet.': '{name} n\'a pas encore d\'adresse.', + 'Deploy it with {command}, or open its page: idem open --dashboard': 'Déployez-la avec {command}, ou ouvrez sa page : idem open --dashboard', + 'Opened {url}': 'Ouvert : {url}', + 'Restart {name}? It is briefly unavailable.': 'Redémarrer {name} ? Elle sera brièvement indisponible.', + 'Not restarted.': 'Pas redémarrée.', + 'Restarting {name}': 'Redémarrage de {name}', + '{name} restarted.': '{name} redémarrée.', + 'Stop {name}? It stops answering until started again.': 'Arrêter {name} ? Elle ne répondra plus jusqu\'à son redémarrage.', + 'Starting {name}': 'Démarrage de {name}', + 'Stopping {name}': 'Arrêt de {name}', + '{name} started.': '{name} démarrée.', + '{name} stopped.': '{name} arrêtée.', + 'Deleting {name} needs --yes.': 'Supprimer {name} demande --yes.', + 'Delete {name}, its data and its address? Type its name to confirm': 'Supprimer {name}, ses données et son adresse ? Tapez son nom pour confirmer', + 'Nothing deleted.': 'Rien supprimé.', + '{name} deleted.': '{name} supprimé(e).', + 'Its server could not be cleaned: it was unreachable.': 'Son serveur n\'a pas pu être nettoyé : il était injoignable.', + '{name} was never deployed.': '{name} n\'a jamais été déployée.', + '"{domain}" is not a domain name.': '« {domain} » n\'est pas un nom de domaine.', + '{host} is already one of its addresses.': '{host} est déjà une de ses adresses.', + '{host} added to {name}.': '{host} ajouté à {name}.', + 'in the DNS zone of {zone}': 'dans la zone DNS de {zone}', + 'At your domain registrar, create': 'Chez votre registrar, créez', + 'HTTPS is issued once the DNS points here. Check: {command}': 'Le HTTPS est émis dès que le DNS pointe ici. Vérifiez : {command}', + '{host} is not an address of {name}.': '{host} n\'est pas une adresse de {name}.', + '{host} removed from {name}.': '{host} retiré de {name}.', + 'Check that the page shows the same code, then authorise.': 'Vérifiez que la page affiche le même code, puis autorisez.', + 'Sign in to iDeploy': 'Connexion à iDeploy', + 'Open the link above in a browser (on any device).': 'Ouvrez le lien ci-dessus dans un navigateur (sur n\'importe quel appareil).', + 'Waiting for your approval in the browser': 'En attente de votre accord dans le navigateur', + 'Signed in as {name}': 'Connecté en tant que {name}', + 'team {team}': 'équipe {team}', + 'Refused in the browser.': 'Refusé dans le navigateur.', + 'Sign-in refused.': 'Connexion refusée.', + 'The code expired.': 'Le code a expiré.', + 'Sign-in timed out.': 'La connexion a expiré.', + 'Run `idem login` again.': 'Relancez `idem login`.', + 'This token is not valid.': 'Ce jeton n\'est pas valide.', + 'Signing in through the browser needs a terminal.': 'La connexion par navigateur nécessite un terminal.', + 'In CI, set IDEM_TOKEN (create one under Security › API tokens), or pass --token.': 'En CI, définissez IDEM_TOKEN (à créer dans Sécurité › Jetons d\'API), ou passez --token.', + 'Next: {command} in your project folder.': 'Ensuite : {command} dans le dossier de votre projet.', + 'You were not signed in.': 'Vous n\'étiez pas connecté.', + 'Signed out. This machine\'s token is revoked.': 'Déconnecté. Le jeton de cette machine est révoqué.', + 'You are not signed in.': 'Vous n\'êtes pas connecté.', + 'Run `idem login`.': 'Lancez `idem login`.', + 'No database yet: {command}.': 'Aucune base pour le moment : {command}.', + 'NAME': 'NOM', + 'ENGINE': 'MOTEUR', + 'STATUS': 'ÉTAT', + 'ID': 'ID', + 'Which engine? {list}': 'Quel moteur ? {list}', + 'Engine': 'Moteur', + 'Unknown engine "{type}".': 'Moteur inconnu « {type} ».', + 'One of: {list}': 'Parmi : {list}', + 'Name': 'Nom', + '{name} ({type}) in workspace {workspace}. Its credentials: {command}': '{name} ({type}) dans le workspace {workspace}. Ses identifiants : {command}', + 'The applications of {workspace} get its address at their next {command} (example values only).': 'Les applications de {workspace} reçoivent son adresse à leur prochain {command} (valeurs d\'exemple uniquement).', + 'Inside the workspace': 'Dans le workspace', + 'Connection URL': 'URL de connexion', + 'From outside': 'Depuis l\'extérieur', + 'Secrets hidden — --reveal shows them.': 'Secrets masqués — --reveal les affiche.', + '{name}: {action} failed.': '{name} : échec de {action}.', + '{name}: done.': '{name} : fait.', + 'Backing up {name}': 'Sauvegarde de {name}', + 'Backup made: {file}': 'Sauvegarde faite : {file}', + 'The backup failed.': 'La sauvegarde a échoué.', + 'Delete {name} ({type}) and all its data? This cannot be undone.': 'Supprimer {name} ({type}) et toutes ses données ? Action irréversible.', + 'Choose one source: {sources}.': 'Choisissez une seule source : {sources}.', + 'In CI, set IDEM_TOKEN (Security › API tokens on iDeploy).': 'En CI, définissez IDEM_TOKEN (Sécurité › Jetons d\'API sur iDeploy).', + '"{name}" no longer exists on iDeploy: a new application is created.': '« {name} » n\'existe plus sur iDeploy : une nouvelle application est créée.', + 'An application "{name}" exists already. Deploy this folder to it?': 'Une application « {name} » existe déjà. Y déployer ce dossier ?', + 'Yes — {name}': 'Oui — {name}', + 'No — create a new application': 'Non — créer une nouvelle application', + 'Reading the project': 'Lecture du projet', + 'Nothing to send.': 'Rien à envoyer.', + 'This folder has no file to deploy.': 'Ce dossier ne contient aucun fichier à déployer.', + 'Run `idem deploy` in your project folder, or deploy a source: --git, --image, --compose, --template.': 'Lancez `idem deploy` dans le dossier de votre projet, ou déployez une source : --git, --image, --compose, --template.', + 'Too large to send.': 'Trop volumineux pour être envoyé.', + 'The project is {size} in {count} files: the CLI sends up to {max} and {maxFiles} files.': 'Le projet fait {size} en {count} fichiers : la CLI envoie jusqu\'à {max} et {maxFiles} fichiers.', + 'The heaviest folders:\n{list}\nAdd what the build does not need to .idemignore (as .gitignore), or deploy from Git: idem deploy --git .': 'Les dossiers les plus lourds :\n{list}\nAjoutez ce dont le build n\'a pas besoin à .idemignore (comme .gitignore), ou déployez depuis Git : idem deploy --git .', + 'Project read: {count} files, {size}': 'Projet lu : {count} fichiers, {size}', + 'its image': 'son image', + 'its repository': 'son dépôt', + '{name} deploys from {what}.': '{name} se déploie depuis {what}.', + '(--upload sends this folder instead)': '(--upload envoie ce dossier à la place)', + 'Sending the code to {name}': 'Envoi du code à {name}', + 'Code sent, but no deployment was queued.': 'Code envoyé, mais aucun déploiement n\'a été lancé.', + 'iDeploy did not queue a deployment.': 'iDeploy n\'a pas lancé de déploiement.', + 'Code sent to {name}': 'Code envoyé à {name}', + 'Workspace (its databases are linked automatically)': 'Workspace (ses bases sont reliées automatiquement)', + 'New workspace "{name}"': 'Nouveau workspace « {name} »', + 'your server {name}': 'votre serveur {name}', + 'Cannot read {file}.': 'Impossible de lire {file}.', + 'Send the {count} variables of .env? ({keys}) — iDeploy replaces local addresses by its own databases': 'Envoyer les {count} variables du .env ? ({keys}) — iDeploy remplace les adresses locales par ses propres bases', + 'Letters, digits and dashes.': 'Lettres, chiffres et tirets.', + 'Give the registry password in IDEM_REGISTRY_PASSWORD (never on the command line).': 'Donnez le mot de passe du registre dans IDEM_REGISTRY_PASSWORD (jamais sur la ligne de commande).', + 'Creating {name}': 'Création de {name}', + 'Not created.': 'Non créé.', + 'iDeploy did not create the application.': 'iDeploy n\'a pas créé l\'application.', + 'Created {name} in workspace {workspace}': '{name} créée dans le workspace {workspace}', + 'Deploying the template {name}': 'Déploiement du modèle {name}', + '{name} is starting in workspace {workspace}': '{name} démarre dans le workspace {workspace}', + 'Its address and passwords: {a} · page on iDeploy: {b}': 'Son adresse et ses mots de passe : {a} · page sur iDeploy : {b}', + 'Creating the service {name}': 'Création du service {name}', + 'Starting {name} (pulling its images)': 'Démarrage de {name} (téléchargement des images)', + '{name} did not start.': '{name} n\'a pas démarré.', + 'The service failed to start — the end of its output is above.': 'Le service a échoué au démarrage — la fin de sa sortie est au-dessus.', + 'Fix the compose file, then: idem services restart {name}': 'Corrigez le fichier compose, puis : idem services restart {name}', + '{name} is running.': '{name} tourne.', + 'Running': 'En marche', + 'Deployment queued. Follow it with {command}': 'Déploiement lancé. Suivez-le avec {command}', + 'Deploying {name}': 'Déploiement de {name}', + '{name} is live': '{name} est en ligne', + 'Done.': 'Terminé.', + 'The deployment was cancelled.': 'Le déploiement a été annulé.', + 'The deployment failed — the cause is in the log above.': 'Le déploiement a échoué — la cause est dans le journal ci-dessus.', + 'The previous version, if any, is still the one serving. Logs: {logs} · page: {page}': 'La version précédente, s\'il y en a une, reste en ligne. Journaux : {logs} · page : {page}', + '{name} has no variable.': '{name} n\'a aucune variable.', + 'Values hidden — --reveal shows them.': 'Valeurs masquées — --reveal les affiche.', + 'Give variables as KEY=value.': 'Donnez les variables sous la forme CLÉ=valeur.', + 'Example: idem env set APP_ENV=production MAIL_HOST=smtp.example.com': 'Exemple : idem env set APP_ENV=production MAIL_HOST=smtp.example.com', + 'Set {keys} on {name}.': '{keys} défini(es) sur {name}.', + 'They apply at the next deployment: {command} (or redeploy from iDeploy).': 'Elles s\'appliquent au prochain déploiement : {command} (ou redéployez depuis iDeploy).', + 'Which variable?': 'Quelle variable ?', + 'Example: idem env rm OLD_KEY': 'Exemple : idem env rm ANCIENNE_CLE', + 'Removed {keys} from {name}.': '{keys} retiré(es) de {name}.', + '{file} exists already.': '{file} existe déjà.', + 'Pass --yes to replace it, or give another file name.': 'Passez --yes pour le remplacer, ou donnez un autre nom de fichier.', + '{file} exists. Replace it?': '{file} existe. Le remplacer ?', + 'Kept as it was.': 'Laissé tel quel.', + '{count} variables written to {file}. Keep it out of Git.': '{count} variables écrites dans {file}. Gardez-le hors de Git.', + '{name} is not running: no container to read.': '{name} ne tourne pas : aucun conteneur à lire.', + 'Deploy it with `idem deploy`, or see `idem status`.': 'Déployez-la avec `idem deploy`, ou voyez `idem status`.', + 'This folder is not deployed yet. One command does it all:': 'Ce dossier n\'est pas encore déployé. Une seule commande fait tout :', + 'Or pick something below. Every command: idem --help': 'Ou choisissez ci-dessous. Toutes les commandes : idem --help', + 'Welcome': 'Bienvenue', + 'Deploy {name}': 'Déployer {name}', + 'Deploy this folder': 'Déployer ce dossier', + 'Logs, live': 'Journaux, en direct', + 'Open in the browser': 'Ouvrir dans le navigateur', + 'Variables': 'Variables', + 'Domains': 'Domaines', + 'Restart': 'Redémarrer', + 'Back to an earlier version': 'Revenir à une version précédente', + 'Link this folder to an existing application': 'Lier ce dossier à une application existante', + 'All my applications': 'Toutes mes applications', + 'Databases, caches, queues': 'Bases, caches, files', + 'Workspaces': 'Workspaces', + 'My servers': 'Mes serveurs', + 'Open iDeploy': 'Ouvrir iDeploy', + 'Sign out': 'Se déconnecter', + 'Quit': 'Quitter', + 'This folder deploys to {name}': 'Ce dossier déploie vers {name}', + 'What do you want to do?': 'Que voulez-vous faire ?', + 'See you.': 'À bientôt.', + '{name} has no earlier version to go back to.': '{name} n\'a pas de version précédente où revenir.', + 'No successful deployment "{id}".': 'Aucun déploiement réussi « {id} ».', + 'Back to which version?': 'Revenir à quelle version ?', + 'current': 'actuelle', + 'Rollback queued ({id}).': 'Retour en arrière lancé ({id}).', + 'Rolling {name} back to {id}': 'Retour de {name} à {id}', + 'The rollback failed — the cause is in the log above.': 'Le retour en arrière a échoué — la cause est dans le journal ci-dessus.', + 'Back on {id}': 'De retour sur {id}', + 'No server of your own. Add one: {command} — or deploy on IDEM\'s infrastructure, nothing to add.': 'Aucun serveur à vous. Ajoutez-en un : {command} — ou déployez sur l\'infrastructure IDEM, rien à ajouter.', + 'ADDRESS': 'ADRESSE', + 'Server address (IP or host name)': 'Adresse du serveur (IP ou nom d\'hôte)', + 'Required.': 'Obligatoire.', + 'Give the address: --ip
.': 'Donnez l\'adresse : --ip .', + 'SSH key iDeploy uses to reach it': 'Clé SSH utilisée par iDeploy pour y accéder', + 'Generate a new key': 'Générer une nouvelle clé', + 'you add its public half to the server': 'vous ajoutez sa partie publique au serveur', + 'Use {file}': 'Utiliser {file}', + 'Another private key file': 'Un autre fichier de clé privée', + 'Path to the private key': 'Chemin de la clé privée', + 'This key has a passphrase: iDeploy needs a key without one.': 'Cette clé a une phrase de passe : iDeploy a besoin d\'une clé sans.', + 'Generate one instead (leave --key out).': 'Générez-en une à la place (sans --key).', + 'Server {name} added.': 'Serveur {name} ajouté.', + 'On {ip}, as {user}, run:': 'Sur {ip}, en tant que {user}, lancez :', + 'Install the public key': 'Installer la clé publique', + 'Done? (iDeploy checks the server next)': 'C\'est fait ? (iDeploy vérifie ensuite le serveur)', + 'Checking {name}': 'Vérification de {name}', + '{name} is ready to deploy.': '{name} est prêt pour le déploiement.', + '{name} is not ready yet.': '{name} n\'est pas encore prêt.', + 'Install Docker and the proxy on it now? (a few minutes)': 'Installer Docker et le proxy maintenant ? (quelques minutes)', + 'When it is reachable: {command}': 'Quand il sera joignable : {command}', + 'Installing Docker and the proxy on {name} (a few minutes)': 'Installation de Docker et du proxy sur {name} (quelques minutes)', + '{name} is set up.': '{name} est prêt.', + 'The setup of {name} failed.': 'L\'installation de {name} a échoué.', + 'Setup failed — the end of its output is above.': 'L\'installation a échoué — la fin de sa sortie est au-dessus.', + 'No answer yet.': 'Pas encore de réponse.', + 'A long install can outlast the request: check it with {command}.': 'Une installation longue peut dépasser la requête : vérifiez avec {command}.', + 'Remove {name} from iDeploy? What runs on it keeps running.': 'Retirer {name} d\'iDeploy ? Ce qui tourne dessus continue de tourner.', + '{name} removed.': '{name} retiré.', + 'No service yet. From the catalogue: {a}, then {b}.': 'Aucun service pour le moment. Depuis le catalogue : {a}, puis {b}.', + 'FROM': 'SOURCE', + 'Delete {name}, its containers and its volumes?': 'Supprimer {name}, ses conteneurs et ses volumes ?', + 'No template matches "{search}".': 'Aucun modèle ne correspond à « {search} ».', + '{count} templates. Deploy one: {command}': '{count} modèles. Pour en déployer un : {command}', + 'No workspace yet. {a} creates one, or {b}.': 'Aucun workspace pour le moment. {a} en crée un, ou {b}.', + 'RUNS ON': 'TOURNE SUR', + 'Where should it run?': 'Où doit-il tourner ?', + 'IDEM\'s infrastructure': 'L\'infrastructure IDEM', + 'nothing to manage': 'rien à gérer', + 'Your server {name}': 'Votre serveur {name}', + 'One of your servers…': 'Un de vos serveurs…', + 'add it first: idem servers add': 'ajoutez-le d\'abord : idem servers add', + 'Add your server first.': 'Ajoutez d\'abord votre serveur.', + '{command}, then run this again.': '{command}, puis relancez ceci.', + 'Region': 'Région', + 'Automatic': 'Automatique', + 'the nearest with room': 'la plus proche avec de la place', + 'Workspace name': 'Nom du workspace', + 'Give a name: idem workspaces create .': 'Donnez un nom : idem workspaces create .', + 'Workspace {name} created ({where}).': 'Workspace {name} créé ({where}).', + 'Runs on': 'Tourne sur', + 'Empty.': 'Vide.', + 'Delete workspace {name}? It must be empty.': 'Supprimer le workspace {name} ? Il doit être vide.', + 'Which shell? bash, zsh or fish.': 'Quel shell ? bash, zsh ou fish.', + 'Example: idem completion bash >> ~/.bashrc': 'Exemple : idem completion bash >> ~/.bashrc', + 'Deploy to iDeploy from your terminal': 'Déployez sur iDeploy depuis votre terminal', + 'Start here, in your project folder:': 'Pour commencer, dans le dossier de votre projet :', + 'Not sure? Run idem alone: a menu shows what you can do.': 'Un doute ? Lancez idem tout seul : un menu montre ce que vous pouvez faire.', + 'Examples': 'Exemples', + 'Options': 'Options', + 'machine-readable output (scripts, CI)': 'sortie lisible par une machine (scripts, CI)', + 'language (default: your system’s)': 'langue (par défaut : celle du système)', + 'the version': 'la version', + 'Every command has its own help:': 'Chaque commande a sa propre aide :', + 'Documentation:': 'Documentation :', + 'Deploy to iDeploy from your terminal. Start with: idem deploy': 'Déployez sur iDeploy depuis votre terminal. Commencez par : idem deploy', + 'language: fr or en': 'langue : fr ou en', + 'this help': 'cette aide', + 'the application (name or id; default: the one of this folder)': 'l\'application (nom ou id ; par défaut : celle de ce dossier)', + 'Deploy — this folder by default; creates the application the first time': 'Déployer — ce dossier par défaut ; crée l\'application la première fois', + 'deploy a Git repository instead of this folder': 'déployer un dépôt Git au lieu de ce dossier', + 'its branch (default: the default branch)': 'sa branche (par défaut : la branche par défaut)', + 'run an image from a registry (registry/org/app:tag)': 'lancer une image d\'un registre (registre/org/app:tag)', + 'the image tag to deploy': 'le tag d\'image à déployer', + 'private registry login (password in IDEM_REGISTRY_PASSWORD)': 'identifiant de registre privé (mot de passe dans IDEM_REGISTRY_PASSWORD)', + 'deploy a docker-compose.yml as a service': 'déployer un docker-compose.yml comme service', + 'deploy a service of the catalogue (see idem templates)': 'déployer un service du catalogue (voir idem templates)', + 'workspace of a new application (created if needed)': 'workspace d\'une nouvelle application (créé si besoin)', + 'a new workspace runs on this server of yours': 'un nouveau workspace tourne sur ce serveur à vous', + 'a new workspace runs on IDEM\'s infrastructure in this region': 'un nouveau workspace tourne sur l\'infrastructure IDEM dans cette région', + 'name of a new application (default: the folder name)': 'nom d\'une nouvelle application (par défaut : le nom du dossier)', + 'the application folder inside a monorepo': 'le dossier de l\'application dans un monorepo', + 'install command': 'commande d\'installation', + 'build command': 'commande de build', + 'start command': 'commande de démarrage', + 'the port the application listens on': 'le port sur lequel l\'application écoute', + 'the built folder of a static site': 'le dossier construit d\'un site statique', + 'send the variables of .env (or of this file) with a new application': 'envoyer les variables du .env (ou de ce fichier) avec une nouvelle application', + 'send this folder even to an application that deploys from Git or an image': 'envoyer ce dossier même à une application qui se déploie depuis Git ou une image', + 'ask nothing: defaults everywhere (CI)': 'ne rien demander : valeurs par défaut partout (CI)', + 'queue the deployment and return without following it': 'lancer le déploiement et rendre la main sans le suivre', + 'Sign in through the browser': 'Se connecter par le navigateur', + 'sign in with an API token instead (CI)': 'se connecter plutôt avec un jeton d\'API (CI)', + 'another iDeploy instance': 'une autre instance iDeploy', + 'Sign out and revoke this machine\'s token': 'Se déconnecter et révoquer le jeton de cette machine', + 'Who you are signed in as': 'Sous quel compte vous êtes connecté', + 'Your applications': 'Vos applications', + 'Forget which application this folder deploys to': 'Oublier vers quelle application ce dossier déploie', + 'Status of the application and its last deployment': 'État de l\'application et de son dernier déploiement', + 'What the running application prints': 'Ce que l\'application en marche affiche', + 'keep printing new lines': 'continuer à afficher les nouvelles lignes', + 'how many of the last lines': 'combien de dernières lignes', + 'Open the application in the browser': 'Ouvrir l\'application dans le navigateur', + 'open its page on iDeploy instead': 'ouvrir plutôt sa page sur iDeploy', + 'Open iDeploy in the browser': 'Ouvrir iDeploy dans le navigateur', + 'Start a stopped application': 'Démarrer une application arrêtée', + 'Stop the application': 'Arrêter l\'application', + 'do not ask': 'ne pas demander', + 'Restart the application': 'Redémarrer l\'application', + 'Delete the application, its data and its address': 'Supprimer l\'application, ses données et son adresse', + 'Deployment history': 'Historique des déploiements', + 'Go back to an earlier version': 'Revenir à une version précédente', + 'the deployment to go back to (id, or its first characters)': 'le déploiement où revenir (id, ou ses premiers caractères)', + 'the previous version, without asking': 'la version précédente, sans demander', + 'do not follow it': 'ne pas le suivre', + 'Variables of the application': 'Variables de l\'application', + 'List them (values hidden)': 'Les lister (valeurs masquées)', + 'show the values': 'afficher les valeurs', + 'Set KEY=value…': 'Définir CLÉ=valeur…', + 'Remove variables': 'Retirer des variables', + 'Write them to a local .env': 'Les écrire dans un .env local', + 'replace an existing file': 'remplacer un fichier existant', + 'Addresses of the application': 'Adresses de l\'application', + 'Its addresses, with DNS and HTTPS state': 'Ses adresses, avec l\'état du DNS et du HTTPS', + 'Add your domain (it becomes the main address)': 'Ajouter votre domaine (il devient l\'adresse principale)', + 'Remove an address': 'Retirer une adresse', + 'Your own servers (IDEM-managed ones need nothing)': 'Vos propres serveurs (ceux gérés par IDEM ne demandent rien)', + 'List them': 'Les lister', + 'Add a server: SSH key, checks, Docker and proxy installed': 'Ajouter un serveur : clé SSH, vérifications, Docker et proxy installés', + 'its name': 'son nom', + 'its address': 'son adresse', + 'SSH port': 'port SSH', + 'SSH user': 'utilisateur SSH', + 'a private key of yours (default: a new key is generated)': 'une clé privée à vous (par défaut : une nouvelle clé est générée)', + 'do not install Docker and the proxy': 'ne pas installer Docker et le proxy', + 'Can iDeploy deploy there?': 'iDeploy peut-il y déployer ?', + 'Install Docker and the proxy': 'Installer Docker et le proxy', + 'Remove a server from iDeploy': 'Retirer un serveur d\'iDeploy', + 'Workspaces: where applications and databases run together': 'Workspaces : là où applications et bases tournent ensemble', + 'Create one — on IDEM\'s infrastructure or on your server': 'En créer un — sur l\'infrastructure IDEM ou sur votre serveur', + 'on this server of yours': 'sur ce serveur à vous', + 'on IDEM\'s infrastructure, in this region': 'sur l\'infrastructure IDEM, dans cette région', + 'a description': 'une description', + 'What runs in it, and how its parts reach each other': 'Ce qui y tourne, et comment ses parties s\'atteignent', + 'Delete an empty workspace': 'Supprimer un workspace vide', + 'Databases, caches and queues ({list})': 'Bases, caches et files ({list})', + 'Create one in a workspace, and start it': 'En créer une dans un workspace, et la démarrer', + 'its workspace': 'son workspace', + 'only create it': 'seulement la créer', + 'Address and credentials': 'Adresse et identifiants', + 'show the secrets': 'afficher les secrets', + 'Back it up now': 'La sauvegarder maintenant', + 'Delete it and its data': 'La supprimer avec ses données', + 'Services: docker-compose stacks and catalogue templates': 'Services : piles docker-compose et modèles du catalogue', + 'Delete it, its containers and volumes': 'Le supprimer, avec ses conteneurs et volumes', + 'The catalogue of one-click services': 'Le catalogue des services en un clic', + 'Tab completion for your shell (bash, zsh, fish)': 'Autocomplétion pour votre shell (bash, zsh, fish)', + 'Run `idem login` — or set IDEM_TOKEN in CI.': 'Lancez `idem login` — ou définissez IDEM_TOKEN en CI.', + 'Several applications are named "{name}".': 'Plusieurs applications s\'appellent « {name} ».', + 'Use its id: `idem apps` lists them.': 'Utilisez son id : `idem apps` les liste.', + 'No application "{name}".': 'Aucune application « {name} ».', + '`idem apps` lists them.': '`idem apps` les liste.', + 'This folder is linked to "{name}", which no longer exists.': 'Ce dossier est lié à « {name} », qui n\'existe plus.', + 'Run `idem unlink`, then `idem deploy`.': 'Lancez `idem unlink`, puis `idem deploy`.', + 'No application given.': 'Aucune application indiquée.', + 'Pass `--app `, or run it in a folder linked with `idem link`.': 'Passez `--app `, ou lancez-la dans un dossier lié avec `idem link`.', + 'Run `idem deploy` in your project folder.': 'Lancez `idem deploy` dans le dossier de votre projet.', + 'Use its id.': 'Utilisez son id.', + 'Give its name or id.': 'Donnez son nom ou son id.', + '{message} — pass --yes to confirm.': '{message} — passez --yes pour confirmer.', + 'Nothing changed.': 'Rien n\'a changé.', + 'Cancelled.': 'Annulé.', + 'Deploy': 'Déployer', + 'Application': 'Application', + 'Infrastructure': 'Infrastructure', + 'Account': 'Compte', + 'Address': 'Adresse', + 'Duration': 'Durée', + 'Linked': 'Liées', + 'Next': 'Ensuite', + 'Workspace': 'Workspace', + 'Deployment': 'Déploiement', + 'Containers': 'Conteneurs', + 'Which server?': 'Quel serveur ?', + 'No server yet.': 'Aucun serveur pour le moment.', + 'No server "{name}".': 'Aucun serveur « {name} ».', + 'Several servers match "{name}".': 'Plusieurs serveurs correspondent à « {name} ».', + 'Which database?': 'Quelle base ?', + 'No database yet.': 'Aucune base pour le moment.', + 'No database "{name}".': 'Aucune base « {name} ».', + 'Several databases match "{name}".': 'Plusieurs bases correspondent à « {name} ».', + 'Which workspace?': 'Quel workspace ?', + 'No workspace yet.': 'Aucun workspace pour le moment.', + 'No workspace "{name}".': 'Aucun workspace « {name} ».', + 'Several workspaces match "{name}".': 'Plusieurs workspaces correspondent à « {name} ».', + 'Which service?': 'Quel service ?', + 'No service yet.': 'Aucun service pour le moment.', + 'No service "{name}".': 'Aucun service « {name} ».', + 'Several services match "{name}".': 'Plusieurs services correspondent à « {name} ».', + 'Start it': 'Le démarrer', + 'Stop it': 'L\'arrêter', + 'Restart it': 'Le redémarrer', + 'this folder — creates the application the first time': 'ce dossier — crée l\'application la première fois', + 'a Git repository': 'un dépôt Git', + 'an image already built': 'une image déjà construite', + 'your own stack, as a service': 'votre propre pile, comme service', + 'a new application, on one of your servers': 'une nouvelle application, sur l\'un de vos serveurs', + 'in CI': 'en CI', + 'opens the browser; check the code, authorise': 'ouvre le navigateur ; vérifiez le code, autorisez', + 'with a token from Security › API tokens': 'avec un jeton de Sécurité › Jetons d\'API', + 'follow it live': 'les suivre en direct', + 'search the last 1000 lines': 'chercher dans les 1000 dernières lignes', + 'choose the version in a list': 'choisir la version dans une liste', + 'straight back to the previous one': 'retour direct à la précédente', + 'list them, values hidden': 'les lister, valeurs masquées', + 'set some; they apply at the next deploy': 'en définir ; elles s\'appliquent au prochain déploiement', + 'write them to a local file': 'les écrire dans un fichier local', + 'its addresses, with DNS and HTTPS state': 'ses adresses, avec l\'état du DNS et du HTTPS', + 'your domain: the DNS record to create is shown': 'votre domaine : l\'enregistrement DNS à créer est affiché', + 'step by step: address, SSH key, checks, Docker': 'pas à pas : adresse, clé SSH, vérifications, Docker', + 'without questions': 'sans questions', + 'can iDeploy deploy there?': 'iDeploy peut-il y déployer ?', + 'on IDEM\'s infrastructure (asked on a terminal)': 'sur l\'infrastructure IDEM (demandé dans un terminal)', + 'on your server': 'sur votre serveur', + 'what runs in it, and how its parts reach each other': 'ce qui y tourne, et comment ses parties s\'atteignent', + 'a database in a workspace, started': 'une base dans un workspace, démarrée', + 'its address and credentials': 'son adresse et ses identifiants', + 'a backup now': 'une sauvegarde maintenant', + 'search the catalogue': 'chercher dans le catalogue', + 'deploy one': 'en déployer un', + 'a one-click service': 'un service en un clic', + 'what the application prints, live': 'ce que l\'application affiche, en direct', + 'a database, linked at the next deploy': 'une base, reliée au prochain déploiement', + 'Unknown command "{name}".': 'Commande inconnue « {name} ».', + 'Did you mean {command}?': 'Vouliez-vous dire {command} ?', + 'Every command: idem --help': 'Toutes les commandes : idem --help', +}; diff --git a/src/shared.ts b/src/shared.ts index 50da314..5858f65 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -8,6 +8,7 @@ import { api, Application, Deployment } from './api.js'; import { readLink, writeLink } from './project.js'; import { token } from './config.js'; import { color, fail, interactive, prompts, unlessCancelled } from './ui.js'; +import { t } from './i18n.js'; /** Opens a page in the default browser; false when there is none (SSH, CI). */ export function openBrowser(url: string): boolean { @@ -29,7 +30,7 @@ export function openBrowser(url: string): boolean { } export function requireSignedIn(): void { - if (!token()) fail('You are not signed in.', 'Run `idem login` — or set IDEM_TOKEN in CI.'); + if (!token()) fail(t('You are not signed in.'), t('Run `idem login` — or set IDEM_TOKEN in CI.')); } export async function listApps(): Promise { @@ -47,23 +48,23 @@ export async function resolveApp(option?: string): Promise { const wanted = option.toLowerCase(); const found = apps.filter((a) => a.uuid === option || a.name.toLowerCase() === wanted); if (found.length === 1) return found[0]; - if (found.length > 1) fail(`Several applications are named "${option}".`, 'Use its id: `idem apps` lists them.'); - fail(`No application "${option}".`, '`idem apps` lists them.'); + if (found.length > 1) fail(t('Several applications are named "{name}".', { name: option }), t('Use its id: `idem apps` lists them.')); + fail(t('No application "{name}".', { name: option }), t('`idem apps` lists them.')); } const link = readLink(); if (link) { try { return await api.get(`/applications/${link.applicationUuid}`); } catch { - fail(`This folder is linked to "${link.applicationName}", which no longer exists.`, 'Run `idem unlink`, then `idem deploy`.'); + fail(t('This folder is linked to "{name}", which no longer exists.', { name: link.applicationName }), t('Run `idem unlink`, then `idem deploy`.')); } } - if (!interactive()) fail('No application given.', 'Pass `--app `, or run it in a folder linked with `idem link`.'); + if (!interactive()) fail(t('No application given.'), t('Pass `--app `, or run it in a folder linked with `idem link`.')); const apps = await listApps(); - if (apps.length === 0) fail('You have no application yet.', 'Run `idem deploy` in your project folder.'); + if (apps.length === 0) fail(t('You have no application yet.'), t('Run `idem deploy` in your project folder.')); const uuid = unlessCancelled( await prompts.select({ - message: 'Which application?', + message: t('Which application?'), options: apps.map((a) => ({ value: a.uuid, label: a.name, hint: a.workspace_name ?? undefined })), }) ); @@ -191,25 +192,36 @@ export function elapsed(since: number): string { * One item of a list, named on the command line (name, id, or the start of * the id) — else chosen from the list on a terminal. */ +export type Kind = 'server' | 'database' | 'workspace' | 'service'; + +/** Whole sentences per kind: a noun pasted into one sentence does not survive French genders. */ +const KIND_TEXT: Record = { + server: { which: 'Which server?', none: 'No server yet.', noSuch: 'No server "{name}".', several: 'Several servers match "{name}".' }, + database: { which: 'Which database?', none: 'No database yet.', noSuch: 'No database "{name}".', several: 'Several databases match "{name}".' }, + workspace: { which: 'Which workspace?', none: 'No workspace yet.', noSuch: 'No workspace "{name}".', several: 'Several workspaces match "{name}".' }, + service: { which: 'Which service?', none: 'No service yet.', noSuch: 'No service "{name}".', several: 'Several services match "{name}".' }, +}; + export async function pick( items: T[], wanted: string | undefined, - what: string, + kind: Kind, hint?: (item: T) => string | undefined ): Promise { + const text = KIND_TEXT[kind]; if (wanted) { const w = wanted.toLowerCase(); const found = items.filter((i) => i.uuid === wanted || i.name.toLowerCase() === w || (w.length >= 6 && i.uuid.startsWith(w))); if (found.length === 1) return found[0]; - if (found.length > 1) fail(`Several ${what}s match "${wanted}".`, 'Use its id.'); - fail(`No ${what} "${wanted}".`); + if (found.length > 1) fail(t(text.several, { name: wanted }), t('Use its id.')); + fail(t(text.noSuch, { name: wanted })); } - if (items.length === 0) fail(`No ${what} yet.`); + if (items.length === 0) fail(t(text.none)); if (items.length === 1) return items[0]; - if (!interactive()) fail(`Which ${what}?`, `Give its name or id.`); + if (!interactive()) fail(t(text.which), t('Give its name or id.')); const uuid = unlessCancelled( await prompts.select({ - message: `Which ${what}?`, + message: t(text.which), options: items.map((i) => ({ value: i.uuid, label: i.name, hint: hint?.(i) })), }) ); @@ -219,10 +231,10 @@ export async function pick( /** Yes/no before something that cannot be undone; `--yes` answers for scripts. */ export async function confirmOrStop(message: string, yes?: boolean): Promise { if (yes) return; - if (!interactive()) fail(`${message} — pass --yes to confirm.`); + if (!interactive()) fail(t('{message} — pass --yes to confirm.', { message })); const ok = unlessCancelled(await prompts.confirm({ message, initialValue: false })); if (!ok) { - prompts.cancel('Nothing changed.'); + prompts.cancel(t('Nothing changed.')); process.exit(0); } } diff --git a/src/ui.ts b/src/ui.ts index 13ca95a..528f9a3 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -5,6 +5,7 @@ import pc from 'picocolors'; import * as p from '@clack/prompts'; import { ApiError } from './api.js'; +import { t } from './i18n.js'; export const color = pc; export const prompts = p; @@ -43,7 +44,7 @@ export function fail(message: string, hint?: string): never { /** A cancelled prompt (Ctrl+C) ends quietly. */ export function unlessCancelled(value: T): Exclude { if (p.isCancel(value)) { - p.cancel('Cancelled.'); + p.cancel(t('Cancelled.')); process.exit(130); } return value as Exclude;