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
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,48 @@ The next times, it just sends the new code and deploys. The folder remembers its

Not sure what to type? Run **`idem`** alone: a menu lists what you can do with the application of this folder.

## Deploy anything

```bash
idem deploy # this folder
idem deploy --git https://github.com/me/app # a repository (--branch, --base-dir for a monorepo)
idem deploy --image ghcr.io/me/api:1.4 # an image (--registry-user + IDEM_REGISTRY_PASSWORD if private)
idem deploy --compose docker-compose.yml # your own stack, as a service
idem deploy --template n8n # a one-click service (idem templates lists them)
```

A new application goes into a **workspace** — everything in it shares one network, and its databases are linked to its applications. It runs on **IDEM's infrastructure** (nothing to manage; `--region` when your plan allows) or on **one of your servers** (`--server <name>`). On a terminal, `idem deploy` asks; in CI, flags answer.

Build settings when the detection needs help: `--base-dir`, `--install`, `--build`, `--start`, `--port`, `--publish-dir`.

## Commands

| Command | |
| Applications | |
|---|---|
| `idem deploy` | Deploy this folder (creates the application the first time). `-y` asks nothing, `-d` does not wait, `-a <app>` targets an application, `--env` sends `.env` with a new one |
| `idem login` / `logout` / `whoami` | Sign in through the browser (`--token` for CI) · sign out and revoke this machine's token |
| `idem deploy` | Deploy (see above). `-y` asks nothing, `-d` does not wait, `-a <app>` targets an application, `--env [file]` sends variables with a new one |
| `idem apps` | Your applications, with their status and address |
| `idem link` / `unlink` | Link this folder to an existing application, or forget it |
| `idem status` · `idem deployments` | The application and its containers · its deployment history |
| `idem logs [-f]` | What the running application prints; `-f` follows it |
| `idem status` | The application, its containers and its last deployment |
| `idem open [--dashboard]` | Open the application — or its page on iDeploy |
| `idem restart` | Restart it (asks first; `-y` does not) |
| `idem restart` · `start` · `stop` | Its lifecycle |
| `idem rollback [--to <id>]` | Back to an earlier version |
| `idem destroy` | Delete it, its data and its address (asks for its name) |
| `idem env [ls\|set\|rm\|pull]` | Variables: list (hidden; `--reveal`), `set KEY=value…`, `rm KEY…`, `pull` into a local `.env` |
| `idem domains [ls\|add\|rm]` | Its addresses with their DNS/HTTPS state; `add` your domain and get the DNS record to create |
| `idem link` · `unlink` | Link this folder to an existing application, or forget it |

| Infrastructure | |
|---|---|
| `idem workspaces [ls\|create\|show\|rm]` | Where things run together; `create --server <name>` or `--region <code>` |
| `idem db [ls\|create\|show\|start\|stop\|restart\|backup\|rm]` | PostgreSQL, MySQL, MariaDB, MongoDB, Redis, KeyDB, Dragonfly, ClickHouse, RabbitMQ; `show --reveal` gives the credentials |
| `idem services [ls\|start\|stop\|restart\|rm]` · `idem templates [search]` | Compose stacks and the catalogue |
| `idem servers [ls\|add\|check\|setup\|rm]` | Your own servers: `add` generates the SSH key (or uses yours), checks the server and installs Docker and the proxy |

| Account | |
|---|---|
| `idem login` · `logout` · `whoami` | Sign in through the browser (`--token` for CI) · sign out and revoke this machine's token |
| `idem dashboard` | Open iDeploy |
| `idem` | A menu of all this, for the application of this folder |

Every command takes `--json` for scripts.

Expand Down
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const api = {
get: <T>(path: string, opts?: RequestOptions) => request<T>('GET', path, undefined, opts),
post: <T>(path: string, body?: unknown, opts?: RequestOptions) => request<T>('POST', path, body ?? {}, opts),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body ?? {}),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body ?? {}),
delete: <T>(path: string) => request<T>('DELETE', path),
};

Expand Down
99 changes: 98 additions & 1 deletion src/commands/apps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** The applications of the team, and linking this folder to one. */
import { api, Application } from '../api.js';
import { readLink, removeLink } from '../project.js';
import { linkFolder, listApps, openBrowser, requireSignedIn, resolveApp } from '../shared.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';

Expand Down Expand Up @@ -111,3 +111,100 @@ export async function dashboard(): Promise<void> {
const url = webUrl();
if (!openBrowser(url)) process.stdout.write(url + '\n');
}

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);
const spin = prompts.spinner();
spin.start(`${action === 'start' ? 'Starting' : 'Stopping'} ${app.name}`);
await api.post(`/applications/${app.uuid}/${action}`);
spin.stop(`${app.name} ${action === 'start' ? 'started' : 'stopped'}.`);
}

/** 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.');
}
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.') : ''}`);
}

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.`);
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'
);
}

// ── Domains ───────────────────────────────────────────────────────────────────

interface DnsGuide {
serverIp: string;
domains: {
host: string;
status: string;
record: { type: string; name: string; value: string; zone: string } | null;
https: { status: string };
}[];
}

function domainsOf(app: Application): string[] {
return (app.fqdn ?? '').split(',').map((d) => d.trim()).filter(Boolean);
}

const bare = (d: string) => d.replace(/^https?:\/\//, '').replace(/\/.*$/, '').toLowerCase();

/** Its addresses, with where their DNS and HTTPS stand. */
export async function domainsList(opts: { app?: string }): Promise<void> {
const app = await resolveApp(opts.app);
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.`);
const byHost = new Map((guide?.domains ?? []).map((d) => [d.host, d]));
process.stdout.write(
table(
domains.map((d, i) => {
const check = byHost.get(bare(d));
const ok = check?.status === 'ok' || check?.status === 'automatic';
const state = check ? `DNS ${check.status} · HTTPS ${check.https.status}` : '';
return [i === 0 ? color.green('●') : color.dim('○'), d, ok ? color.green(state) : color.yellow(state)];
})
) + '\n'
);
}

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.`);
const current = domainsOf(app);
if (current.some((d) => bare(d) === host)) return void prompts.log.info(`${host} is already one of its addresses.`);
// 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}.`);
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')}`);
}

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}.`);
await api.patch<Application>(`/applications/${app.uuid}`, { fqdn: kept.join(',') });
if (!printJson({ removed: host })) prompts.log.success(`${host} removed from ${app.name}.`);
}
121 changes: 121 additions & 0 deletions src/commands/databases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Databases, caches and queues of a workspace: PostgreSQL, MySQL, MariaDB,
* MongoDB, Redis, KeyDB, Dragonfly, ClickHouse, RabbitMQ. Created next to the
* applications of the workspace, on its network; `idem deploy` links them.
*/
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';

export const ENGINES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'keydb', 'dragonfly', 'clickhouse', 'rabbitmq'] as const;

interface Database {
uuid: string;
name: string;
type: string;
status: string | null;
is_public?: boolean;
public_port?: number | null;
}

interface DatabaseDetail extends Database {
credentials: Record<string, string>;
internal_host: string;
port: number;
connection_url: string | null;
public_connection_url: string | null;
}

const listDatabases = () => api.get<Database[]>('/databases');

async function databaseOf(wanted?: string): Promise<Database> {
requireSignedIn();
return pick(await listDatabases(), wanted, 'database', (d) => `${d.type}${d.status ? ` · ${d.status}` : ''}`);
}

function dot(status: string | null): string {
return (status ?? '').startsWith('running') ? color.green('●') : (status ?? '').startsWith('exited') ? color.red('●') : color.dim('○');
}

export async function dbList(): Promise<void> {
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');
}

export async function dbCreate(engine: string | undefined, opts: { name?: string; workspace?: string; start?: boolean }): Promise<void> {
requireSignedIn();
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 (!(ENGINES as readonly string[]).includes(type)) fail(`Unknown engine "${type}".`, `One of: ${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}`);

const created = await api.post<Database>(`/databases/${type}`, { name, workspace_uuid: ws.uuid });
if (opts.start !== false) {
const spin = interactive() ? prompts.spinner() : null;
spin?.start(`Starting ${name}`);
const r = await api.post<{ success?: boolean }>(`/databases/${type}/${created.uuid}/start`).catch((e: Error) => ({ success: false, error: e.message }));
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).`);
}

export async function dbShow(wanted: string | undefined, opts: { reveal?: boolean }): Promise<void> {
const db = await databaseOf(wanted);
const d = await api.get<DatabaseDetail>(`/databases/${db.type}/${db.uuid}`);
const hide = (v: string | null) => (v && !opts.reveal ? v.replace(/:\/\/([^:@/]*):([^@]*)@/, '://$1:••••@') : v);
if (printJson(opts.reveal ? d : { ...d, credentials: undefined, connection_url: hide(d.connection_url), public_connection_url: hide(d.public_connection_url) })) return;
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)}`] : []),
'',
...creds,
].join('\n'),
d.name
);
if (!opts.reveal) prompts.log.info(color.dim('Secrets hidden — --reveal shows them.'));
}

export async function dbAction(action: 'start' | 'stop' | 'restart', wanted: string | undefined): Promise<void> {
const db = await databaseOf(wanted);
const spin = prompts.spinner();
spin.start(`${action === 'start' ? 'Starting' : action === 'stop' ? 'Stopping' : 'Restarting'} ${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);
fail(r.output?.split('\n').slice(-6).join('\n') || `${action} failed.`);
}
spin.stop(`${db.name}: done.`);
}

export async function dbBackup(wanted: string | undefined): Promise<void> {
const db = await databaseOf(wanted);
const spin = prompts.spinner();
spin.start(`Backing up ${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);
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<void> {
const db = await databaseOf(wanted);
await confirmOrStop(`Delete ${db.name} (${db.type}) and all its data? This cannot be undone.`, opts.yes);
await api.delete(`/databases/${db.type}/${db.uuid}`);
if (!printJson({ removed: db.uuid })) prompts.log.success(`${db.name} deleted.`);
}
Loading
Loading