deploro 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +558 -19
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -230,6 +230,10 @@ Downloading ${keys.length} file(s) to ${opts.storage} \u2026`);
230
230
  }
231
231
  }
232
232
 
233
+ // src/commands/gen.ts
234
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
235
+ import { dirname as dirname3 } from "path";
236
+
233
237
  // src/lib/api.ts
234
238
  function errorMessage(status, body, statusText) {
235
239
  const parts = [body.error ?? statusText];
@@ -268,6 +272,237 @@ async function apiRequestNoProject(path, options) {
268
272
  return res.json();
269
273
  }
270
274
 
275
+ // src/lib/gen-types.ts
276
+ var STRING_NUMERIC = /* @__PURE__ */ new Set(["int8", "numeric", "money"]);
277
+ var NUMBER = /* @__PURE__ */ new Set(["int2", "int4", "float4", "float8", "oid", "xid", "cid", "regclass", "regproc", "regtype"]);
278
+ var STRING = /* @__PURE__ */ new Set([
279
+ "text",
280
+ "varchar",
281
+ "bpchar",
282
+ "char",
283
+ "name",
284
+ "citext",
285
+ "uuid",
286
+ "inet",
287
+ "cidr",
288
+ "macaddr",
289
+ "macaddr8",
290
+ "date",
291
+ "time",
292
+ "timetz",
293
+ "timestamp",
294
+ "timestamptz",
295
+ "interval",
296
+ "xml",
297
+ "tsvector",
298
+ "tsquery",
299
+ "bit",
300
+ "varbit",
301
+ "point",
302
+ "line",
303
+ "lseg",
304
+ "box",
305
+ "path",
306
+ "polygon",
307
+ "circle",
308
+ "ltree"
309
+ ]);
310
+ var DATA_TYPE_TO_UDT = {
311
+ smallint: "int2",
312
+ integer: "int4",
313
+ bigint: "int8",
314
+ real: "float4",
315
+ "double precision": "float8",
316
+ numeric: "numeric",
317
+ money: "money",
318
+ boolean: "bool",
319
+ text: "text",
320
+ "character varying": "varchar",
321
+ character: "bpchar",
322
+ uuid: "uuid",
323
+ json: "json",
324
+ jsonb: "jsonb",
325
+ date: "date",
326
+ bytea: "bytea",
327
+ "timestamp without time zone": "timestamp",
328
+ "timestamp with time zone": "timestamptz",
329
+ "time without time zone": "time",
330
+ "time with time zone": "timetz",
331
+ interval: "interval"
332
+ };
333
+ var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
334
+ function key(name) {
335
+ return IDENT_RE.test(name) ? name : JSON.stringify(name);
336
+ }
337
+ function literal(value) {
338
+ return JSON.stringify(value);
339
+ }
340
+ function udtOf(prop) {
341
+ if (prop["x-pg-type"]) return prop["x-pg-type"];
342
+ const dataType = prop.description?.replace(/ NOT NULL$/, "");
343
+ return dataType ? DATA_TYPE_TO_UDT[dataType] ?? null : null;
344
+ }
345
+ function tsType(udt, enums, write, prop) {
346
+ if (udt?.startsWith("_")) {
347
+ const inner = tsType(udt.slice(1), enums, write);
348
+ return /[|\s]/.test(inner) ? `(${inner})[]` : `${inner}[]`;
349
+ }
350
+ if (udt && enums[udt]) return `Database["public"]["Enums"][${literal(udt)}]`;
351
+ if (udt === "bool") return "boolean";
352
+ if (udt === "json" || udt === "jsonb") return "Json";
353
+ if (udt && NUMBER.has(udt)) return "number";
354
+ if (udt && STRING_NUMERIC.has(udt)) return write ? "number | string" : "string";
355
+ if (udt && STRING.has(udt)) return "string";
356
+ if (udt === "void") return "undefined";
357
+ if (prop?.enum?.length) return prop.enum.map(literal).join(" | ");
358
+ const t = Array.isArray(prop?.type) ? null : prop?.type;
359
+ if (t === "number" || t === "integer") return "number";
360
+ if (t === "boolean") return "boolean";
361
+ if (t === "string") return "string";
362
+ if (t === "array") return "unknown[]";
363
+ return "unknown";
364
+ }
365
+ function generateTypes(spec) {
366
+ const schemas = spec.components?.schemas ?? {};
367
+ const enums = spec.components?.["x-pg-enums"] ?? {};
368
+ const tableNames = Object.keys(schemas).sort();
369
+ let legacySpec = false;
370
+ const tableBlocks = [];
371
+ for (const table2 of tableNames) {
372
+ const schema = schemas[table2];
373
+ const required = new Set(schema.required ?? []);
374
+ const row = [];
375
+ const insert = [];
376
+ const update = [];
377
+ for (const [col, prop] of Object.entries(schema.properties ?? {})) {
378
+ if (!("x-pg-type" in prop)) legacySpec = true;
379
+ const nullable = prop.nullable === true || !required.has(col);
380
+ const udt = udtOf(prop);
381
+ const readType = tsType(udt, enums, false, prop) + (nullable ? " | null" : "");
382
+ const writeType = tsType(udt, enums, true, prop) + (nullable ? " | null" : "");
383
+ const neverWritten = prop["x-pg-generated"] === true || prop["x-pg-identity"] === "always";
384
+ const optionalOnInsert = nullable || prop["x-pg-has-default"] === true;
385
+ row.push(` ${key(col)}: ${readType}`);
386
+ if (neverWritten) {
387
+ insert.push(` ${key(col)}?: never`);
388
+ update.push(` ${key(col)}?: never`);
389
+ } else {
390
+ insert.push(` ${key(col)}${optionalOnInsert ? "?" : ""}: ${writeType}`);
391
+ update.push(` ${key(col)}?: ${writeType}`);
392
+ }
393
+ }
394
+ const body = (lines) => lines.length ? `{
395
+ ${lines.join("\n")}
396
+ }` : "Record<string, never>";
397
+ tableBlocks.push(
398
+ ` ${key(table2)}: {
399
+ Row: ${body(row)}
400
+ Insert: ${body(insert)}
401
+ Update: ${body(update)}
402
+ }`
403
+ );
404
+ }
405
+ const functionBlocks = [];
406
+ const fnEntries = Object.entries(spec.paths ?? {}).map(([path, ops]) => ({ name: path.match(/\/studio\/rpc\/([^/]+)$/)?.[1], op: ops.post })).filter((f) => !!f.name && !!f.op).filter((f) => !["trigger", "event_trigger"].includes(f.op["x-pg-returns"]?.type ?? "")).sort((a, b) => a.name.localeCompare(b.name));
407
+ for (const { name, op } of fnEntries) {
408
+ const props = op.requestBody?.content?.["application/json"]?.schema?.properties ?? {};
409
+ const args = Object.entries(props).map(([arg, prop]) => {
410
+ const optional = prop["x-pg-has-default"] === true;
411
+ return ` ${key(arg)}${optional ? "?" : ""}: ${tsType(udtOf(prop), enums, true, prop)}`;
412
+ });
413
+ const ret = op["x-pg-returns"];
414
+ let returns;
415
+ if (!ret) {
416
+ legacySpec = true;
417
+ returns = "unknown";
418
+ } else {
419
+ let single;
420
+ if (ret.columns?.length) {
421
+ single = `{
422
+ ${ret.columns.map((c) => ` ${key(c.name)}: ${tsType(c.type, enums, false)} | null`).join("\n")}
423
+ }`;
424
+ } else if (ret.type && schemas[ret.type]) {
425
+ single = `Database["public"]["Tables"][${literal(ret.type)}]["Row"]`;
426
+ } else {
427
+ single = tsType(ret.type, enums, false);
428
+ }
429
+ returns = ret.set ? /[|\s]/.test(single) && !single.startsWith("{") ? `(${single})[]` : `${single}[]` : single;
430
+ }
431
+ functionBlocks.push(
432
+ ` ${key(name)}: {
433
+ Args: ${args.length ? `{
434
+ ${args.join("\n")}
435
+ }` : "Record<PropertyKey, never>"}
436
+ Returns: ${returns}
437
+ }`
438
+ );
439
+ }
440
+ const enumBlocks = Object.keys(enums).sort().map(
441
+ (name) => ` ${key(name)}: ${enums[name].length ? enums[name].map(literal).join(" | ") : "never"}`
442
+ );
443
+ const section = (blocks) => blocks.length ? `{
444
+ ${blocks.join("\n")}
445
+ }` : "{\n [_ in never]: never\n }";
446
+ const source = `// Generated by \`deploro gen types\`. Do not edit by hand; re-run after each migration.
447
+ //
448
+ // bigint, numeric and money columns are strings in Row: Deploro returns them as
449
+ // strings so large values keep their precision. Insert and Update accept either.
450
+
451
+ export type Json =
452
+ | string
453
+ | number
454
+ | boolean
455
+ | null
456
+ | { [key: string]: Json | undefined }
457
+ | Json[]
458
+
459
+ export type Database = {
460
+ public: {
461
+ Tables: ${section(tableBlocks)}
462
+ Functions: ${section(functionBlocks)}
463
+ Enums: ${section(enumBlocks)}
464
+ }
465
+ }
466
+
467
+ type PublicSchema = Database["public"]
468
+
469
+ export type Tables<T extends keyof PublicSchema["Tables"]> = PublicSchema["Tables"][T]["Row"]
470
+ export type TablesInsert<T extends keyof PublicSchema["Tables"]> = PublicSchema["Tables"][T]["Insert"]
471
+ export type TablesUpdate<T extends keyof PublicSchema["Tables"]> = PublicSchema["Tables"][T]["Update"]
472
+ export type Enums<T extends keyof PublicSchema["Enums"]> = PublicSchema["Enums"][T]
473
+ export type FunctionArgs<T extends keyof PublicSchema["Functions"]> = PublicSchema["Functions"][T]["Args"]
474
+ export type FunctionReturns<T extends keyof PublicSchema["Functions"]> = PublicSchema["Functions"][T]["Returns"]
475
+ `;
476
+ return {
477
+ source,
478
+ tables: tableNames.length,
479
+ functions: fnEntries.length,
480
+ enums: enumBlocks.length,
481
+ legacySpec
482
+ };
483
+ }
484
+
485
+ // src/commands/gen.ts
486
+ async function genTypesCommand(opts) {
487
+ const config = requireProject();
488
+ const spec = await apiRequest(`/api/projects/${config.projectId}/studio/spec`);
489
+ const result = generateTypes(spec);
490
+ if (opts.out) {
491
+ mkdirSync3(dirname3(opts.out) || ".", { recursive: true });
492
+ writeFileSync3(opts.out, result.source, "utf8");
493
+ console.error(
494
+ `Wrote ${opts.out}: ${result.tables} table(s), ${result.functions} function(s), ${result.enums} enum(s)`
495
+ );
496
+ } else {
497
+ process.stdout.write(result.source);
498
+ }
499
+ if (result.legacySpec) {
500
+ console.error(
501
+ "Warning: this server's spec has no column defaults or function return types, so every NOT NULL column is required in Insert and function Returns are `unknown`. They'll be exact once the server is updated."
502
+ );
503
+ }
504
+ }
505
+
271
506
  // src/lib/confirm.ts
272
507
  import { createInterface as createInterface2 } from "readline";
273
508
  async function confirmOrDie(question, yes) {
@@ -359,9 +594,10 @@ async function dbTablesCommand() {
359
594
  }
360
595
  async function dbQueryCommand(sql, opts) {
361
596
  requireProject();
597
+ const params = opts.param?.length ? opts.param : void 0;
362
598
  const data = await apiRequest(
363
599
  "/api/db/query",
364
- { method: "POST", body: JSON.stringify({ sql }) }
600
+ { method: "POST", body: JSON.stringify({ sql, params }) }
365
601
  );
366
602
  if (opts.json) {
367
603
  json(data.rows);
@@ -593,6 +829,23 @@ async function tokenRevokeCommand(id, opts) {
593
829
  await apiRequestNoProject(`/api/auth/tokens/${encodeURIComponent(id)}`, { method: "DELETE" });
594
830
  console.log(`\u2713 Token ${id} revoked`);
595
831
  }
832
+ async function tokenRotateCommand(id, opts) {
833
+ const config = requireConfig();
834
+ requireSessionAuth(config);
835
+ const graceHours = Number(opts.graceHours ?? 0);
836
+ const body = { graceHours };
837
+ if (opts.days !== void 0) body.expiresInDays = Number(opts.days);
838
+ const data = await apiRequestNoProject(
839
+ `/api/auth/tokens/${encodeURIComponent(id)}/rotate`,
840
+ { method: "POST", body: JSON.stringify(body) }
841
+ );
842
+ console.log("\u2713 Token rotated. Copy the new one now, it will not be shown again:");
843
+ console.log(`
844
+ ${data.token}
845
+ `);
846
+ console.log(` Expires : in ${data.expiresInDays} days`);
847
+ console.log(data.oldTokenValidForHours ? ` The old token keeps working for ${data.oldTokenValidForHours} hour(s).` : " The old token no longer works.");
848
+ }
596
849
  function isEmailProvider(provider) {
597
850
  return provider === "email" || provider === "email_password";
598
851
  }
@@ -886,8 +1139,273 @@ async function resolveDomainId(projectId, idOrHostname) {
886
1139
  return match.id;
887
1140
  }
888
1141
 
1142
+ // src/commands/zones.ts
1143
+ import { writeFileSync as writeFileSync4 } from "fs";
1144
+ function zonesPath() {
1145
+ return `/api/projects/${requireProject().projectId}/zones`;
1146
+ }
1147
+ async function findZone(name) {
1148
+ const { zones: zones2 } = await apiRequest(zonesPath());
1149
+ const n = name.trim().toLowerCase();
1150
+ const match = zones2.filter((z) => n === z.name || n.endsWith(`.${z.name}`)).sort((a, b) => b.name.length - a.name.length)[0];
1151
+ if (!match) {
1152
+ console.error(`This project has no managed domain matching "${name}". Run: deploro zones list`);
1153
+ process.exit(1);
1154
+ }
1155
+ return match;
1156
+ }
1157
+ function printNameservers(z) {
1158
+ console.log(`
1159
+ At your registrar, replace every nameserver for ${z.name} with:
1160
+ `);
1161
+ for (const ns of z.name_servers) console.log(` ${ns}`);
1162
+ if (z.original_name_servers.length) console.log(`
1163
+ (currently: ${z.original_name_servers.join(", ")})`);
1164
+ console.log(`
1165
+ Then run: deploro zones check ${z.name}`);
1166
+ }
1167
+ async function zonesListCommand() {
1168
+ const { zones: zones2 } = await apiRequest(zonesPath());
1169
+ if (!zones2.length) {
1170
+ console.log("This project has no managed domains. Add one with: deploro zones add <domain>");
1171
+ return;
1172
+ }
1173
+ table(zones2.map((z) => ({ domain: z.name, status: z.status, serving: z.hostname_count ?? 0 })));
1174
+ }
1175
+ async function zonesAddCommand(domain) {
1176
+ const data = await apiRequest(zonesPath(), { method: "POST", body: JSON.stringify({ name: domain }) });
1177
+ console.log(`\u2713 ${data.zone.name} added.
1178
+ `);
1179
+ console.log(`Copied ${data.imported_records.length} existing record(s) \u2014 check nothing you use is missing:`);
1180
+ for (const r of data.imported_records) console.log(` ${r.type.padEnd(6)} ${r.name} \u2192 ${r.priority ?? ""} ${r.content}`);
1181
+ for (const f of data.import_failed ?? []) console.log(` \u2717 ${f.record.type} ${f.record.name}: ${f.error}`);
1182
+ console.log("DNS can't list every name a domain has \u2014 add anything else with 'deploro zones records add' before switching.");
1183
+ if (data.legacy_hostnames.length) {
1184
+ console.log(`
1185
+ Already connected via CNAME: ${data.legacy_hostnames.join(", ")} \u2014 move them over with 'deploro zones attach' once live.`);
1186
+ }
1187
+ printNameservers(data.zone);
1188
+ }
1189
+ async function zonesStatusCommand(domain) {
1190
+ const z = await findZone(domain);
1191
+ const { zone } = await apiRequest(`${zonesPath()}/${z.id}`);
1192
+ console.log(`${zone.name}: ${zone.status}`);
1193
+ if (zone.status !== "active") printNameservers(zone);
1194
+ }
1195
+ async function zonesCheckCommand(domain) {
1196
+ const z = await findZone(domain);
1197
+ const { zone } = await apiRequest(`${zonesPath()}/${z.id}/check`, { method: "POST" });
1198
+ if (zone.status === "active") console.log(`\u2713 ${zone.name} is live on Deploro`);
1199
+ else console.log(`Not switched yet \u2014 nameserver changes can take a few hours. Deploro keeps checking and emails you when it's live.`);
1200
+ }
1201
+ async function zonesRemoveCommand(domain, opts) {
1202
+ const z = await findZone(domain);
1203
+ await confirmOrDie(
1204
+ `Remove ${z.name}? If its nameservers still point at Deploro, its website AND email stop resolving immediately.`,
1205
+ opts.yes
1206
+ );
1207
+ const data = await apiRequest(`${zonesPath()}/${z.id}`, {
1208
+ method: "DELETE",
1209
+ body: JSON.stringify({ confirm: z.name })
1210
+ });
1211
+ if (data.zone_file) {
1212
+ const file = `${z.name}.zone.txt`;
1213
+ writeFileSync4(file, data.zone_file);
1214
+ console.log(`Saved its records to ${file}`);
1215
+ }
1216
+ console.log(`\u2713 ${z.name} removed`);
1217
+ }
1218
+ async function zonesRecordsListCommand(domain) {
1219
+ const z = await findZone(domain);
1220
+ const { records } = await apiRequest(`${zonesPath()}/${z.id}/records`);
1221
+ table(
1222
+ records.map((r) => ({
1223
+ id: r.id,
1224
+ type: r.type,
1225
+ name: r.name,
1226
+ content: r.priority !== null && r.type === "MX" ? `${r.priority} ${r.content}` : r.content,
1227
+ proxied: r.proxied ? "yes" : "",
1228
+ managed: r.managed_by ?? ""
1229
+ }))
1230
+ );
1231
+ }
1232
+ async function zonesRecordsAddCommand(domain, type, name, content, opts) {
1233
+ const z = await findZone(domain);
1234
+ const { record } = await apiRequest(`${zonesPath()}/${z.id}/records`, {
1235
+ method: "POST",
1236
+ body: JSON.stringify({
1237
+ type,
1238
+ name,
1239
+ content,
1240
+ ...opts.ttl ? { ttl: Number(opts.ttl) } : {},
1241
+ ...opts.priority ? { priority: Number(opts.priority) } : {},
1242
+ ...opts.proxied ? { proxied: true } : {}
1243
+ })
1244
+ });
1245
+ console.log(`\u2713 Added ${record.type} ${record.name} \u2192 ${record.content} (id: ${record.id})`);
1246
+ }
1247
+ async function zonesRecordsRemoveCommand(domain, recordId, opts) {
1248
+ const z = await findZone(domain);
1249
+ await confirmOrDie(`Delete record ${recordId} from ${z.name}?`, opts.yes);
1250
+ await apiRequest(`${zonesPath()}/${z.id}/records/${recordId}`, { method: "DELETE" });
1251
+ console.log("\u2713 Record deleted");
1252
+ }
1253
+ async function zonesAttachCommand(hostname, opts) {
1254
+ const z = await findZone(hostname);
1255
+ const h = hostname.trim().toLowerCase();
1256
+ try {
1257
+ const data = await apiRequest(
1258
+ `${zonesPath()}/${z.id}/hostnames`,
1259
+ { method: "POST", body: JSON.stringify({ hostname: h, replace: !!opts.replace }) }
1260
+ );
1261
+ if (data.attached) console.log(`\u2713 https://${data.hostname.hostname} now serves this project`);
1262
+ else console.log(`Saved, but not attached yet: ${data.error}`);
1263
+ } catch (err) {
1264
+ const msg = err.message;
1265
+ if (msg.includes("already points somewhere")) {
1266
+ console.error(`${h} already has DNS records or a CNAME connection. Re-run with --replace to point it at this project instead.`);
1267
+ process.exit(1);
1268
+ }
1269
+ throw err;
1270
+ }
1271
+ }
1272
+ async function zonesDetachCommand(hostname, opts) {
1273
+ const z = await findZone(hostname);
1274
+ const h = hostname.trim().toLowerCase();
1275
+ const { hostnames } = await apiRequest(`${zonesPath()}/${z.id}/hostnames`);
1276
+ const match = hostnames.find((x) => x.hostname === h);
1277
+ if (!match) {
1278
+ console.error(`${h} isn't serving this project.`);
1279
+ process.exit(1);
1280
+ }
1281
+ await confirmOrDie(`Stop serving this project on ${h}? It stops immediately.`, opts.yes);
1282
+ await apiRequest(`${zonesPath()}/${z.id}/hostnames/${match.id}`, { method: "DELETE" });
1283
+ console.log(`\u2713 ${h} detached`);
1284
+ }
1285
+ var FORWARDING = "cf_routing";
1286
+ async function zonesEmailStatusCommand(domain) {
1287
+ const z = await findZone(domain);
1288
+ const s = await apiRequest(`${zonesPath()}/${z.id}/email`);
1289
+ const names = new Map(s.presets.map((p) => [p.kind, p.name]));
1290
+ const active = s.services.map((x) => x.kind === FORWARDING ? "Forwarding" : names.get(x.kind) ?? x.kind);
1291
+ console.log(`Email on ${z.name}: ${active.length ? active.join(", ") : "nothing set up"}`);
1292
+ if (s.forwarding.enabled) {
1293
+ console.log("\nForwarding:");
1294
+ for (const r of s.forwarding.rules) {
1295
+ console.log(` ${r.address} \u2192 ${r.destination}${r.enabled ? "" : " (paused until the inbox confirms)"}`);
1296
+ }
1297
+ const ca = s.forwarding.catch_all;
1298
+ console.log(` everything else \u2192 ${ca?.enabled ? ca.destination : "rejected"}`);
1299
+ }
1300
+ if (s.destinations.length) {
1301
+ console.log("\nInboxes:");
1302
+ for (const d of s.destinations) {
1303
+ const state = d.usable ? "confirmed" : `waiting for them to click the ${d.waiting_for === "deploro" ? "Deploro" : "Cloudflare"} email`;
1304
+ console.log(` ${d.email} ${state}`);
1305
+ }
1306
+ }
1307
+ console.log(`
1308
+ Providers: ${s.presets.map((p) => p.kind).join(", ")} \u2014 or 'deploro zones email-forwarding on ${z.name}'`);
1309
+ }
1310
+ async function zonesEmailSetupCommand(domain, provider, opts) {
1311
+ const z = await findZone(domain);
1312
+ const config = {};
1313
+ for (const kv of opts.set ?? []) {
1314
+ const i = kv.indexOf("=");
1315
+ if (i < 1) {
1316
+ console.error(`--set expects key=value, got "${kv}"`);
1317
+ process.exit(1);
1318
+ }
1319
+ config[kv.slice(0, i)] = kv.slice(i + 1);
1320
+ }
1321
+ try {
1322
+ await apiRequest(`${zonesPath()}/${z.id}/email/presets/${provider}`, {
1323
+ method: "POST",
1324
+ body: JSON.stringify({ config, replace: !!opts.replace })
1325
+ });
1326
+ console.log(`\u2713 ${provider} is set up on ${z.name}`);
1327
+ } catch (err) {
1328
+ if (err.message.includes("replaces existing records")) {
1329
+ console.error(
1330
+ `${z.name} already has mail records this would replace \u2014 see 'deploro zones records list ${z.name}'. Re-run with --replace to replace them.`
1331
+ );
1332
+ process.exit(1);
1333
+ }
1334
+ throw err;
1335
+ }
1336
+ }
1337
+ async function zonesEmailRemoveCommand(domain, provider, opts) {
1338
+ const z = await findZone(domain);
1339
+ await confirmOrDie(`Remove ${provider} from ${z.name}? Its DNS records are deleted and mail stops arriving there.`, opts.yes);
1340
+ await apiRequest(`${zonesPath()}/${z.id}/email/presets/${provider}`, { method: "DELETE" });
1341
+ console.log(`\u2713 ${provider} removed`);
1342
+ }
1343
+ async function zonesEmailForwardingCommand(mode, domain, opts) {
1344
+ const z = await findZone(domain);
1345
+ if (mode === "on") {
1346
+ try {
1347
+ await apiRequest(`${zonesPath()}/${z.id}/email/forwarding`, {
1348
+ method: "POST",
1349
+ body: JSON.stringify({ replace: !!opts.replace })
1350
+ });
1351
+ } catch (err) {
1352
+ if (err.message.includes("replaces the domain's existing mail servers")) {
1353
+ console.error(`${z.name} already has MX records. Re-run with --replace to replace them with forwarding.`);
1354
+ process.exit(1);
1355
+ }
1356
+ throw err;
1357
+ }
1358
+ console.log(`\u2713 Forwarding is on for ${z.name}. Next: deploro zones forward hello@${z.name} you@gmail.com`);
1359
+ } else if (mode === "off") {
1360
+ await confirmOrDie(`Turn off forwarding for ${z.name}? Mail to it will bounce.`, opts.yes);
1361
+ await apiRequest(`${zonesPath()}/${z.id}/email/forwarding`, { method: "DELETE" });
1362
+ console.log("\u2713 Forwarding is off");
1363
+ } else {
1364
+ console.error("Use: deploro zones email-forwarding on|off <domain>");
1365
+ process.exit(1);
1366
+ }
1367
+ }
1368
+ async function zonesForwardCommand(address, inbox) {
1369
+ const [local, domain] = address.toLowerCase().split("@");
1370
+ if (!local || !domain) {
1371
+ console.error("The first argument is the address to forward, like hello@yourdomain.com");
1372
+ process.exit(1);
1373
+ }
1374
+ const z = await findZone(domain);
1375
+ const { projectId } = requireProject();
1376
+ const email = inbox.trim().toLowerCase();
1377
+ let s = await apiRequest(`${zonesPath()}/${z.id}/email`);
1378
+ let dest = s.destinations.find((d) => d.email === email);
1379
+ if (!dest) {
1380
+ await apiRequest(`/api/projects/${projectId}/email-destinations`, { method: "POST", body: JSON.stringify({ email }) });
1381
+ s = await apiRequest(`${zonesPath()}/${z.id}/email`);
1382
+ dest = s.destinations.find((d) => d.email === email);
1383
+ }
1384
+ if (!dest?.usable) {
1385
+ console.log(`${email} has been sent a confirmation email. Once they click it, run this command again.`);
1386
+ return;
1387
+ }
1388
+ await apiRequest(`${zonesPath()}/${z.id}/email/rules`, {
1389
+ method: "POST",
1390
+ body: JSON.stringify({ local, destination_id: dest.id })
1391
+ });
1392
+ console.log(`\u2713 ${local}@${z.name} \u2192 ${email}`);
1393
+ }
1394
+ async function zonesUnforwardCommand(address) {
1395
+ const domain = address.toLowerCase().split("@")[1] ?? "";
1396
+ const z = await findZone(domain);
1397
+ const s = await apiRequest(`${zonesPath()}/${z.id}/email`);
1398
+ const rule = s.forwarding.rules.find((r) => r.address === address.toLowerCase());
1399
+ if (!rule) {
1400
+ console.error(`${address} isn't forwarded.`);
1401
+ process.exit(1);
1402
+ }
1403
+ await apiRequest(`${zonesPath()}/${z.id}/email/rules/${rule.id}`, { method: "DELETE" });
1404
+ console.log(`\u2713 ${address} no longer forwards`);
1405
+ }
1406
+
889
1407
  // src/commands/storage.ts
890
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
1408
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync5 } from "fs";
891
1409
  import { basename } from "path";
892
1410
  function authHeaders2(config) {
893
1411
  return { Authorization: `Bearer ${config.accessToken}` };
@@ -930,27 +1448,27 @@ async function storageUploadCommand(filePath, opts) {
930
1448
  const data = await res.json();
931
1449
  console.log(`\u2713 Uploaded as ${data.key} (${data.size} bytes, ${data.contentType})`);
932
1450
  }
933
- async function storageDownloadCommand(key, opts) {
1451
+ async function storageDownloadCommand(key2, opts) {
934
1452
  const config = requireProject();
935
1453
  const res = await fetch(
936
- `${config.baseUrl}/api/projects/${config.projectId}/storage/download?key=${encodeURIComponent(key)}`,
1454
+ `${config.baseUrl}/api/projects/${config.projectId}/storage/download?key=${encodeURIComponent(key2)}`,
937
1455
  { headers: authHeaders2(config) }
938
1456
  );
939
1457
  await throwOnError2(res);
940
1458
  const bytes = new Uint8Array(await res.arrayBuffer());
941
- const outPath = opts.out ?? basename(key);
942
- writeFileSync3(outPath, bytes);
1459
+ const outPath = opts.out ?? basename(key2);
1460
+ writeFileSync5(outPath, bytes);
943
1461
  console.log(`\u2713 Saved to ${outPath} (${bytes.length} bytes)`);
944
1462
  }
945
- async function storageDeleteCommand(key, opts) {
1463
+ async function storageDeleteCommand(key2, opts) {
946
1464
  const config = requireProject();
947
- await confirmOrDie(`Delete "${key}" from storage?`, opts.yes);
1465
+ await confirmOrDie(`Delete "${key2}" from storage?`, opts.yes);
948
1466
  const res = await fetch(
949
- `${config.baseUrl}/api/projects/${config.projectId}/storage?key=${encodeURIComponent(key)}`,
1467
+ `${config.baseUrl}/api/projects/${config.projectId}/storage?key=${encodeURIComponent(key2)}`,
950
1468
  { method: "DELETE", headers: authHeaders2(config) }
951
1469
  );
952
1470
  await throwOnError2(res);
953
- console.log(`\u2713 Deleted ${key}`);
1471
+ console.log(`\u2713 Deleted ${key2}`);
954
1472
  }
955
1473
  async function storagePublicCommand(mode, opts) {
956
1474
  const config = requireProject();
@@ -1052,13 +1570,13 @@ function parseDuration(input) {
1052
1570
  const mult = { s: 1, m: 60, h: 3600, d: 86400 }[unit] ?? 1;
1053
1571
  return n * mult;
1054
1572
  }
1055
- async function storageSignCommand(key, opts) {
1573
+ async function storageSignCommand(key2, opts) {
1056
1574
  const config = requireProject();
1057
1575
  const expiresIn = opts.expires ? parseDuration(opts.expires) : void 0;
1058
1576
  const res = await fetch(`${config.baseUrl}/api/projects/${config.projectId}/storage/signed-url`, {
1059
1577
  method: "POST",
1060
1578
  headers: { ...authHeaders2(config), "Content-Type": "application/json" },
1061
- body: JSON.stringify({ key, expiresIn })
1579
+ body: JSON.stringify({ key: key2, expiresIn })
1062
1580
  });
1063
1581
  await throwOnError2(res);
1064
1582
  const data = await res.json();
@@ -1315,13 +1833,13 @@ async function hostingStatusCommand() {
1315
1833
  }
1316
1834
  console.log("");
1317
1835
  }
1318
- async function hostingSetEnvCommand(key, value) {
1836
+ async function hostingSetEnvCommand(key2, value) {
1319
1837
  requireConfig();
1320
1838
  await apiRequest("/api/hosting/env", {
1321
1839
  method: "POST",
1322
- body: JSON.stringify({ key, value })
1840
+ body: JSON.stringify({ key: key2, value })
1323
1841
  });
1324
- console.log(`\u2713 Secret '${key}' updated`);
1842
+ console.log(`\u2713 Secret '${key2}' updated`);
1325
1843
  }
1326
1844
  async function hostingEnvCommand() {
1327
1845
  requireConfig();
@@ -1659,13 +2177,13 @@ async function vpsEnvCommand() {
1659
2177
  for (const v of data.vars) console.log(` ${v.key}`);
1660
2178
  console.log("");
1661
2179
  }
1662
- async function vpsSetEnvCommand(key, value) {
2180
+ async function vpsSetEnvCommand(key2, value) {
1663
2181
  const config = requireProject();
1664
2182
  await apiRequest(`/api/projects/${config.projectId}/vps/env`, {
1665
2183
  method: "POST",
1666
- body: JSON.stringify({ key, value })
2184
+ body: JSON.stringify({ key: key2, value })
1667
2185
  });
1668
- console.log(`\u2713 VPS env var '${key}' set \u2014 run 'deploro vps deploy' to apply it`);
2186
+ console.log(`\u2713 VPS env var '${key2}' set \u2014 run 'deploro vps deploy' to apply it`);
1669
2187
  }
1670
2188
  async function vpsDeployCommand() {
1671
2189
  const config = requireProject();
@@ -1803,12 +2321,14 @@ program.command("create <name>").description("Create a new project (and set it a
1803
2321
  program.command("use <project>").description("Set the active project (accepts id, slug, or name)").action(useProjectCommand);
1804
2322
  program.command("delete <project>").description("Delete a project and its database (accepts id, slug, or name)").option("-y, --yes", "Skip confirmation prompt").action(projectsDeleteCommand);
1805
2323
  program.command("export").description("Export the active project's database and storage manifest (NDJSON)").option("--out <file>", "Where to write the export (default: deploro-export-<date>.ndjson)").option("--storage <dir>", "Also download stored files into this directory").option("--skip-storage", "Skip the object-storage listing entirely").action(exportCommand);
2324
+ var gen = program.command("gen").description("Generate code from the active project");
2325
+ gen.command("types").description("TypeScript types for the project's tables, functions and enums (prints to stdout)").option("--out <file>", "Write to this file instead of stdout").action(genTypesCommand);
1806
2326
  var db = program.command("db").description("Database commands");
1807
2327
  db.command("create").description("Provision the active project's database (idempotent)").action(dbCreateCommand);
1808
2328
  db.command("info").description("Show database status and how to reach it from a deployed app").action(dbInfoCommand);
1809
2329
  db.command("tables").description("List tables in the active project").action(dbTablesCommand);
1810
2330
  db.command("rows <table>").description("List rows in a table").option("--limit <n>", "Rows per page (max 200)", "20").option("--page <n>", "Page number", "1").option("--json", "Output raw JSON").action(dbRowsCommand);
1811
- db.command("query <sql>").description("Run a SQL query (project admin)").option("--json", "Output raw JSON").action(dbQueryCommand);
2331
+ db.command("query <sql>").description("Run a read-only SQL query (project admin)").option("--param <value>", "Bind a value to $1, $2, ... in order (repeatable)", (v, prev) => [...prev, v], []).option("--json", "Output raw JSON").action(dbQueryCommand);
1812
2332
  db.command("schema <table>").description("Show schema for a table").action(dbSchemaCommand);
1813
2333
  db.command("insert <table>").description("Insert a row").requiredOption("--data <json>", "Row data as a JSON object").action(dbInsertCommand);
1814
2334
  db.command("update <table> <id>").description("Update a row by id").requiredOption("--data <json>", "Fields to update as a JSON object").action(dbUpdateCommand);
@@ -1840,6 +2360,7 @@ authEndUsers.command("delete <id>").description("Delete an end user and all thei
1840
2360
  var token = program.command("token").description("Personal access token commands");
1841
2361
  token.command("list").description("List your personal access tokens").action(tokenListCommand);
1842
2362
  token.command("create <name>").description("Create a new project-scoped personal access token").option("--read", "Grant read scope").option("--write", "Grant write scope").option("--project <slugs>", "Comma-separated project slugs/ids/names (defaults to the active project)").option("--days <n>", "Expiry in days (1-365)", "90").action(tokenCreateCommand);
2363
+ token.command("rotate <id>").description("Replace a token with a new one (same name, scopes and projects)").option("--grace-hours <n>", "Keep the old token working for this many hours (0-72)", "0").option("--days <n>", "Expiry of the new token in days (1-365, default: the old token's lifetime)").action(tokenRotateCommand);
1843
2364
  token.command("revoke <id>").description("Revoke a personal access token").option("--yes", "Skip confirmation prompt").action(tokenRevokeCommand);
1844
2365
  var hosting = program.command("hosting").description("Hosting commands");
1845
2366
  hosting.command("status").description("Show Worker and VPS health").action(hostingStatusCommand);
@@ -1889,6 +2410,24 @@ domains.command("add <hostname>").description("Add a domain \u2014 prints the CN
1889
2410
  domains.command("verify <id-or-hostname>").description("Verify DNS ownership and register SSL").action(domainsVerifyCommand);
1890
2411
  domains.command("status <id-or-hostname>").description("Poll domain verification/SSL status").action(domainsStatusCommand);
1891
2412
  domains.command("remove <id-or-hostname>").description("Remove a domain").option("-y, --yes", "Skip confirmation prompt").action(domainsRemoveCommand);
2413
+ var zones = program.command("zones").description("The active project's domains whose DNS Deploro manages (switch nameservers once, then manage everything)");
2414
+ zones.command("list").description("List the project's managed domains").action(zonesListCommand);
2415
+ zones.command("add <domain>").description("Add a domain \u2014 copies its current records and prints the nameservers to switch to").action(zonesAddCommand);
2416
+ zones.command("status <domain>").description("Show whether the nameserver switch has taken effect").action(zonesStatusCommand);
2417
+ zones.command("check <domain>").description("Ask Deploro to re-check the nameservers now").action(zonesCheckCommand);
2418
+ zones.command("remove <domain>").description("Remove a domain (saves its records to a zone file first)").option("-y, --yes", "Skip confirmation prompt").action(zonesRemoveCommand);
2419
+ var zoneRecords = zones.command("records").description("DNS records of a managed domain");
2420
+ zoneRecords.command("list <domain>").description("List records").action(zonesRecordsListCommand);
2421
+ zoneRecords.command("add <domain> <type> <name> <content>").description("Add a record (name: @ for the root, or e.g. www)").option("--ttl <seconds>", "TTL (default auto)").option("--priority <n>", "MX priority (default 10)").option("--proxied", "Proxy through Cloudflare (A/AAAA/CNAME only)").action(zonesRecordsAddCommand);
2422
+ zoneRecords.command("rm <domain> <record-id>").description("Delete a record").option("-y, --yes", "Skip confirmation prompt").action(zonesRecordsRemoveCommand);
2423
+ zones.command("attach <hostname>").description("Serve the active project on a managed domain or subdomain").option("--replace", "Replace records or a CNAME connection already at that hostname").action(zonesAttachCommand);
2424
+ zones.command("detach <hostname>").description("Stop serving the active project on that hostname").option("-y, --yes", "Skip confirmation prompt").action(zonesDetachCommand);
2425
+ zones.command("email <domain>").description("Show email setup: providers, forwarding, inboxes").action(zonesEmailStatusCommand);
2426
+ zones.command("email-setup <domain> <provider>").description("Set up google_workspace | microsoft_365 | zoho | fastmail | resend").option("--set <key=value...>", "Provider values, e.g. --set region=eu dkim_selector=zmail").option("--replace", "Replace existing mail records").action(zonesEmailSetupCommand);
2427
+ zones.command("email-remove <domain> <provider>").description("Remove a provider and its records").option("-y, --yes", "Skip confirmation prompt").action(zonesEmailRemoveCommand);
2428
+ zones.command("email-forwarding <mode> <domain>").description("Turn free email forwarding on or off (mode: on|off)").option("--replace", "Replace existing MX records").option("-y, --yes", "Skip confirmation prompt").action(zonesEmailForwardingCommand);
2429
+ zones.command("forward <address> <inbox>").description("Forward e.g. hello@yourdomain.com to you@gmail.com (the inbox confirms once)").action(zonesForwardCommand);
2430
+ zones.command("unforward <address>").description("Stop forwarding an address").action(zonesUnforwardCommand);
1892
2431
  var storage = program.command("storage").description("R2 file storage for the active project");
1893
2432
  storage.command("list").description("List stored files").action(storageListCommand);
1894
2433
  storage.command("upload <file>").description("Upload a local file").option("--key <name>", "Stored file name (defaults to the local file's name)").option("--bucket <name>", "Upload into an existing bucket instead of the project root").action(storageUploadCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deploro",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Command-line interface for the Deploro developer platform — manage projects, databases, deployments, and hosting from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {