Skip to content
Open
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
153 changes: 153 additions & 0 deletions bin/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* pinme db <subcommand>
*
* pinme db migrate [--dry-run] — run pending migrations against remote D1
* pinme db migrate:create <name> — create a new migration file
* pinme db query "<sql>" [--json] — execute SQL on remote D1
*/

import chalk from 'chalk';
import path from 'path';
import fs from 'fs-extra';
import { createHash } from 'crypto';
import { readWorkerConfig, readProjectData } from './utils/worker-config';
import { runDbMigrations, queryDb, WorkerApiError } from './utils/worker-api';
import { getAuthConfig } from './utils/auth';

function requireProject(): { projectId: string } {
const auth = getAuthConfig();
if (!auth) { console.log(chalk.red('Not logged in. Run: pinme login')); process.exit(1); }
const projectData = readProjectData();
if (!projectData) {
console.log(chalk.red('No project found in current directory. Run: pinme worker deploy'));
process.exit(1);
}
return { projectId: projectData.project_id };
}

export async function dbMigrate(opts: { dryRun?: boolean }): Promise<void> {
const { projectId } = requireProject();

let config;
try {
config = readWorkerConfig();
} catch (e: any) {
console.log(chalk.red(e.message));
process.exit(1);
}

if (!config.d1) {
console.log(chalk.red('No [d1] section in pinme.toml. Add migrations_dir to use migrations.'));
process.exit(1);
}

const migrationsDir = path.join(process.cwd(), config.d1.migrations_dir);
if (!fs.existsSync(migrationsDir)) {
console.log(chalk.yellow(`Migrations directory not found: ${config.d1.migrations_dir}`));
return;
}

const files = fs.readdirSync(migrationsDir)
.filter((f: string) => f.endsWith('.sql'))
.sort();

if (files.length === 0) {
console.log(chalk.yellow('No migration files found.'));
return;
}

if (opts.dryRun) {
console.log(chalk.cyan('Migrations that would run:'));
for (const f of files) console.log(` ${f}`);
return;
}

const migrations = files.map((filename: string) => {
const sql = fs.readFileSync(path.join(migrationsDir, filename), 'utf-8');
const checksum = createHash('sha256').update(sql).digest('hex');
return { filename, sql, checksum };
});

try {
const result = await runDbMigrations(projectId, migrations);
if (result.applied.length === 0) {
console.log(chalk.green('All migrations already applied.'));
} else {
for (const m of result.applied) console.log(chalk.green(` Applied: ${m}`));
}
if (result.skipped.length > 0) {
console.log(chalk.gray(` Skipped (already applied): ${result.skipped.join(', ')}`));
}
} catch (e: any) {
console.log(chalk.red(`Migration failed: ${e.message}`));
process.exit(1);
}
}

export function dbMigrateCreate(name: string): void {
let config;
try {
config = readWorkerConfig();
} catch (e: any) {
console.log(chalk.red(e.message));
process.exit(1);
}

const migrationsDir = path.join(process.cwd(), config.d1?.migrations_dir ?? 'schema');
fs.mkdirpSync(migrationsDir);

const existing = fs.readdirSync(migrationsDir)
.filter((f: string) => f.endsWith('.sql'))
.sort();

let nextNum = 1;
if (existing.length > 0) {
const last = parseInt(existing[existing.length - 1].split('_')[0], 10);
if (!isNaN(last)) nextNum = last + 1;
}

const numStr = String(nextNum).padStart(3, '0');
const safeName = name.replace(/[^a-z0-9_]/gi, '_').toLowerCase();
const filename = `${numStr}_${safeName}.sql`;
fs.writeFileSync(
path.join(migrationsDir, filename),
`-- Migration: ${filename}\n-- Created: ${new Date().toISOString()}\n\n`,
);

console.log(chalk.green(`Created: ${config.d1?.migrations_dir ?? 'schema'}/${filename}`));
}

export async function dbQuery(sql: string, opts: { json?: boolean }): Promise<void> {
const { projectId } = requireProject();

try {
const result = await queryDb(projectId, sql);
const rows = result.results as Record<string, unknown>[];

if (opts.json) {
console.log(JSON.stringify(rows, null, 2));
return;
}

if (rows.length === 0) {
console.log(chalk.gray('No results.'));
return;
}

const keys = Object.keys(rows[0]);
console.log(' ' + keys.join('\t'));
console.log(' ' + keys.map(() => '---').join('\t'));
for (const row of rows) {
console.log(' ' + keys.map((k) => String(row[k] ?? '')).join('\t'));
}
console.log('');
console.log(chalk.gray(`${rows.length} row${rows.length === 1 ? '' : 's'} (${result.meta.duration_ms.toFixed(1)}ms)`));
} catch (e: any) {
if (e instanceof WorkerApiError) {
console.log(chalk.red(`Query failed: ${e.message}`));
} else {
console.log(chalk.red(`Error: ${e.message}`));
}
process.exit(1);
}
}
144 changes: 143 additions & 1 deletion bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ import logoutCmd from './logout';
import showAppKeyCmd from './show-appkey';
import myDomainsCmd from './my-domains';
import bindCmd from './bind';
import loginCmd from './login';
import {
workerInit,
workerDeploy,
workerStatus,
workerDestroy,
workerLogs,
workerDev,
workerList,
workerSecretSet,
workerSecretList,
workerSecretDelete,
workerSecretImport,
} from './worker';
import { dbMigrate, dbMigrateCreate, dbQuery } from './db';
import whoamiCmd from './whoami';

// display the ASCII art logo
function showBanner(): void {
Expand Down Expand Up @@ -98,6 +114,116 @@ program
.description("Alias for 'my-domains' command")
.action(() => myDomainsCmd());

// ── Auth ──────────────────────────────────────────────────────────────────────

program
.command('login')
.description('Log in with your email (sends a verification code)')
.option('--email <email>', 'Email address (skips the prompt)')
.action((opts: { email?: string }) => loginCmd(opts));

program
.command('whoami')
.description('Show current account identity and tier')
.action(() => whoamiCmd());

// ── Worker backend ────────────────────────────────────────────────────────────

const workerCmd = program
.command('worker')
.description('Manage Cloudflare Worker backends');

workerCmd
.command('init [name]')
.description('Initialize a new worker project')
.option('--template <name>', 'Template: blank (default) or rest-api')
.action((name: string | undefined, opts: { template?: string }) => workerInit(name, opts));

workerCmd
.command('deploy')
.description('Build and deploy the worker in the current directory')
.option('--message <msg>', 'Deploy message')
.option('--dry-run', 'Preview without deploying')
.action((opts: { message?: string; dryRun?: boolean }) => workerDeploy(opts));

workerCmd
.command('status')
.description('Show worker status and usage')
.action(() => workerStatus());

workerCmd
.command('destroy')
.description('Permanently destroy the worker and its database')
.option('--confirm', 'Skip confirmation prompt')
.action((opts: { confirm?: boolean }) => workerDestroy(opts));

workerCmd
.command('logs')
.description('Stream live logs from the deployed worker')
.action(() => workerLogs());

workerCmd
.command('dev')
.description('Start local development server (requires wrangler)')
.option('--port <number>', 'Port to listen on (default 8787)')
.action((opts: { port?: string }) => workerDev(opts));

workerCmd
.command('list')
.description('List all your worker projects')
.action(() => workerList());

// ── Worker secrets ────────────────────────────────────────────────────────────

const workerSecretCmd = workerCmd
.command('secret')
.description('Manage worker secrets (environment variables)');

workerSecretCmd
.command('set <key> [value]')
.description('Set a secret (prompts for value if not provided)')
.action((key: string, value: string | undefined) => workerSecretSet(key, value));

workerSecretCmd
.command('list')
.description('List secret names (values are never shown)')
.action(() => workerSecretList());

workerSecretCmd
.command('delete <key>')
.description('Delete a secret')
.action((key: string) => workerSecretDelete(key));

workerSecretCmd
.command('import <file>')
.description('Import secrets from a .env file')
.action((file: string) => workerSecretImport(file));

// ── Database ──────────────────────────────────────────────────────────────────

const dbCmd = program
.command('db')
.description("Manage the worker's D1 database");

dbCmd
.command('migrate')
.description('Run pending SQL migrations against the remote database')
.option('--dry-run', 'Show pending migrations without applying')
.action((opts: { dryRun?: boolean }) => dbMigrate(opts));

dbCmd
.command('migrate:create <name>')
.description('Create a new migration file in the migrations directory')
.action((name: string) => dbMigrateCreate(name));

dbCmd
.command('query <sql>')
.description('Execute a SQL query on the remote database')
.option('--json', 'Output results as JSON')
.action((sql: string, opts: { json?: boolean }) => dbQuery(sql, opts));

// ── IPFS history ──────────────────────────────────────────────────────────────

program
.command('list')
.description('show upload history')
Expand Down Expand Up @@ -153,13 +279,29 @@ program.on('--help', () => {
console.log(' $ pinme export <cid> --output <path>');
console.log(' $ pinme rm <hash>');
console.log(' $ pinme set-appkey <AppKey>');
console.log(' $ pinme login');
console.log(' $ pinme show-appkey');
console.log(' $ pinme logout');
console.log(' $ pinme whoami');
console.log(' $ pinme my-domains');
console.log(' $ pinme domain');
console.log(' $ pinme list -l 5');
console.log(' $ pinme ls');
console.log(' $ pinme help');
console.log('');
console.log('Worker backends (Cloudflare):');
console.log(' $ pinme worker init my-api --template rest-api');
console.log(' $ pinme worker deploy');
console.log(' $ pinme worker status');
console.log(' $ pinme worker logs');
console.log(' $ pinme worker dev');
console.log(' $ pinme worker destroy');
console.log(' $ pinme worker list');
console.log(' $ pinme worker secret set API_KEY');
console.log(' $ pinme worker secret list');
console.log(' $ pinme worker secret delete API_KEY');
console.log(' $ pinme worker secret import .env');
console.log(' $ pinme db migrate');
console.log(' $ pinme db query "SELECT * FROM items"');
console.log('');
console.log(
'For more information, visit: https://github.com/glitternetwork/pinme',
Expand Down
Loading