Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --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`:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
78 changes: 40 additions & 38 deletions src/commands/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -17,14 +18,14 @@ export async function apps(): Promise<void> {
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');
}
Expand All @@ -36,27 +37,27 @@ export async function link(opts: { app?: string }): Promise<void> {
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 <name>`.');
if (!process.stdin.isTTY) fail(t('Which application?'), t('Pass `--app <name>`.'));
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<void> {
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 {
Expand All @@ -75,36 +76,37 @@ export async function status(opts: { app?: string }): Promise<void> {
]);
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<void> {
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<void> {
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<void> {
Expand All @@ -114,32 +116,32 @@ export async function dashboard(): Promise<void> {

export async function startStop(action: 'start' | 'stop', opts: { app?: string; yes?: boolean }): Promise<void> {
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<void> {
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<void> {
const app = await resolveApp(opts.app);
const list = await api.get<DeploymentRow[]>(`/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'
Expand Down Expand Up @@ -170,7 +172,7 @@ export async function domainsList(opts: { app?: string }): Promise<void> {
const guide = await api.get<DnsGuide>(`/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(
Expand All @@ -187,24 +189,24 @@ export async function domainsList(opts: { app?: string }): Promise<void> {
export async function domainsAdd(domain: string, opts: { app?: string }): Promise<void> {
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<Application>(`/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<DnsGuide>(`/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<void> {
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<Application>(`/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 }));
}
35 changes: 18 additions & 17 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,14 +43,14 @@ function machine(): string {
export async function browserLogin(): Promise<Me> {
const started = await api.post<Started>('/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);
Expand All @@ -61,17 +62,17 @@ export async function browserLogin(): Promise<Me> {
}
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<void> {
Expand All @@ -80,36 +81,36 @@ export async function login(opts: { token?: string; api?: string }): Promise<voi
try {
const me = await saveSignedIn(opts.token, opts.api);
if (!printJson({ signedIn: true, name: me.name, team: me.team?.name ?? null })) {
prompts.log.success(`Signed in as ${color.bold(me.name)}`);
prompts.log.success(t('Signed in as {name}', { name: color.bold(me.name) }));
}
} catch (err) {
fail(err instanceof ApiError && err.status === 401 ? 'This token is not valid.' : (err as Error).message);
fail(err instanceof ApiError && err.status === 401 ? t('This token is not valid.') : (err as Error).message);
}
return;
}
if (!interactive()) fail('Signing in through the browser needs a terminal.', 'In CI, set IDEM_TOKEN (create one under Security › API tokens), or pass --token.');
if (!interactive()) fail(t('Signing in through the browser needs a terminal.'), t('In CI, set IDEM_TOKEN (create one under Security › API tokens), or pass --token.'));
prompts.intro(color.bgCyan(color.black(' idem ')));
if (opts.api) writeConfig({ ...readConfig(), apiUrl: opts.api });
await browserLogin();
prompts.outro(`Next: ${color.cyan('idem deploy')} in your project folder.`);
prompts.outro(t('Next: {command} in your project folder.', { command: color.cyan('idem deploy') }));
}

export async function logout(): Promise<void> {
const current = readConfig().token;
if (!current) {
prompts.log.info('You were not signed in.');
prompts.log.info(t('You were not signed in.'));
return;
}
// `<id>|<secret>`: 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<void> {
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>('/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())}`);
}
Loading
Loading