@specific.dev/cli 0.1.67 → 0.1.68

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 (77) hide show
  1. package/dist/admin/404/index.html +1 -1
  2. package/dist/admin/404.html +1 -1
  3. package/dist/admin/__next.!KGRlZmF1bHQp.__PAGE__.txt +2 -2
  4. package/dist/admin/__next.!KGRlZmF1bHQp.txt +5 -5
  5. package/dist/admin/__next._full.txt +9 -9
  6. package/dist/admin/__next._head.txt +1 -1
  7. package/dist/admin/__next._index.txt +4 -4
  8. package/dist/admin/__next._tree.txt +2 -2
  9. package/dist/admin/_next/static/chunks/47a5dab862795de7.js +1 -0
  10. package/dist/admin/_next/static/chunks/{77284e343252b102.js → 63a5ddab5f6a075d.js} +2 -2
  11. package/dist/admin/_next/static/chunks/{c7954d71061f1f9b.js → 71775bad64b386a3.js} +2 -2
  12. package/dist/admin/_next/static/chunks/8cd2655984f0da65.js +1 -0
  13. package/dist/admin/_next/static/chunks/bac545c7353852cd.css +4 -0
  14. package/dist/admin/_not-found/__next._full.txt +4 -4
  15. package/dist/admin/_not-found/__next._head.txt +1 -1
  16. package/dist/admin/_not-found/__next._index.txt +4 -4
  17. package/dist/admin/_not-found/__next._not-found.__PAGE__.txt +1 -1
  18. package/dist/admin/_not-found/__next._not-found.txt +1 -1
  19. package/dist/admin/_not-found/__next._tree.txt +2 -2
  20. package/dist/admin/_not-found/index.html +1 -1
  21. package/dist/admin/_not-found/index.txt +4 -4
  22. package/dist/admin/databases/__next.!KGRlZmF1bHQp.databases.__PAGE__.txt +2 -2
  23. package/dist/admin/databases/__next.!KGRlZmF1bHQp.databases.txt +1 -1
  24. package/dist/admin/databases/__next.!KGRlZmF1bHQp.txt +5 -5
  25. package/dist/admin/databases/__next._full.txt +9 -9
  26. package/dist/admin/databases/__next._head.txt +1 -1
  27. package/dist/admin/databases/__next._index.txt +4 -4
  28. package/dist/admin/databases/__next._tree.txt +2 -2
  29. package/dist/admin/databases/index.html +1 -1
  30. package/dist/admin/databases/index.txt +9 -9
  31. package/dist/admin/fullscreen/__next._full.txt +5 -5
  32. package/dist/admin/fullscreen/__next._head.txt +1 -1
  33. package/dist/admin/fullscreen/__next._index.txt +4 -4
  34. package/dist/admin/fullscreen/__next._tree.txt +2 -2
  35. package/dist/admin/fullscreen/__next.fullscreen.__PAGE__.txt +2 -2
  36. package/dist/admin/fullscreen/__next.fullscreen.txt +1 -1
  37. package/dist/admin/fullscreen/databases/__next._full.txt +5 -5
  38. package/dist/admin/fullscreen/databases/__next._head.txt +1 -1
  39. package/dist/admin/fullscreen/databases/__next._index.txt +4 -4
  40. package/dist/admin/fullscreen/databases/__next._tree.txt +2 -2
  41. package/dist/admin/fullscreen/databases/__next.fullscreen.databases.__PAGE__.txt +2 -2
  42. package/dist/admin/fullscreen/databases/__next.fullscreen.databases.txt +1 -1
  43. package/dist/admin/fullscreen/databases/__next.fullscreen.txt +1 -1
  44. package/dist/admin/fullscreen/databases/index.html +1 -1
  45. package/dist/admin/fullscreen/databases/index.txt +5 -5
  46. package/dist/admin/fullscreen/index.html +1 -1
  47. package/dist/admin/fullscreen/index.txt +5 -5
  48. package/dist/admin/index.html +1 -1
  49. package/dist/admin/index.txt +9 -9
  50. package/dist/admin/mail/__next.!KGRlZmF1bHQp.mail.__PAGE__.txt +9 -0
  51. package/dist/admin/mail/__next.!KGRlZmF1bHQp.mail.txt +4 -0
  52. package/dist/admin/mail/__next.!KGRlZmF1bHQp.txt +8 -0
  53. package/dist/admin/mail/__next._full.txt +27 -0
  54. package/dist/admin/mail/__next._head.txt +6 -0
  55. package/dist/admin/mail/__next._index.txt +7 -0
  56. package/dist/admin/mail/__next._tree.txt +5 -0
  57. package/dist/admin/mail/index.html +1 -0
  58. package/dist/admin/mail/index.txt +27 -0
  59. package/dist/admin/workflows/__next.!KGRlZmF1bHQp.txt +5 -5
  60. package/dist/admin/workflows/__next.!KGRlZmF1bHQp.workflows.__PAGE__.txt +2 -2
  61. package/dist/admin/workflows/__next.!KGRlZmF1bHQp.workflows.txt +1 -1
  62. package/dist/admin/workflows/__next._full.txt +9 -9
  63. package/dist/admin/workflows/__next._head.txt +1 -1
  64. package/dist/admin/workflows/__next._index.txt +4 -4
  65. package/dist/admin/workflows/__next._tree.txt +2 -2
  66. package/dist/admin/workflows/index.html +1 -1
  67. package/dist/admin/workflows/index.txt +9 -9
  68. package/dist/cli.js +235 -19
  69. package/dist/docs/index.md +3 -0
  70. package/dist/docs/mail.md +66 -0
  71. package/dist/postinstall.js +1 -1
  72. package/package.json +5 -1
  73. package/dist/admin/_next/static/chunks/497f00630c8a5681.js +0 -1
  74. package/dist/admin/_next/static/chunks/8342a9e3e2851626.css +0 -4
  75. /package/dist/admin/_next/static/{B_l0oWRS4jgPRx3kI3HDj → w4VP36_YGzWIvqWZUyEgj}/_buildManifest.js +0 -0
  76. /package/dist/admin/_next/static/{B_l0oWRS4jgPRx3kI3HDj → w4VP36_YGzWIvqWZUyEgj}/_clientMiddlewareManifest.json +0 -0
  77. /package/dist/admin/_next/static/{B_l0oWRS4jgPRx3kI3HDj → w4VP36_YGzWIvqWZUyEgj}/_ssgManifest.js +0 -0
package/dist/cli.js CHANGED
@@ -184496,7 +184496,7 @@ function trackEvent(event, properties) {
184496
184496
  event,
184497
184497
  properties: {
184498
184498
  ...properties,
184499
- cli_version: "0.1.67",
184499
+ cli_version: "0.1.68",
184500
184500
  platform: process.platform,
184501
184501
  node_version: process.version,
184502
184502
  project_id: getProjectId(),
@@ -184868,6 +184868,10 @@ var BETA_REGISTRY = [
184868
184868
  {
184869
184869
  name: "temporal",
184870
184870
  description: "Managed Temporal workflow engine for durable workflows and background tasks"
184871
+ },
184872
+ {
184873
+ name: "mail",
184874
+ description: "Managed email sending via SMTP for transactional emails"
184871
184875
  }
184872
184876
  ];
184873
184877
 
@@ -184938,10 +184942,11 @@ function filterBetaTags(content, enabledBetas) {
184938
184942
  );
184939
184943
  }
184940
184944
  function resolveDocContent(path30) {
184945
+ const normalized = path30?.replace(/^\/+|\/+$/g, "") || void 0;
184941
184946
  if (_embeddedDocs) {
184942
- return resolveEmbeddedDoc(path30);
184947
+ return resolveEmbeddedDoc(normalized);
184943
184948
  }
184944
- return resolveFilesystemDoc(path30);
184949
+ return resolveFilesystemDoc(normalized);
184945
184950
  }
184946
184951
  function resolveEmbeddedDoc(path30) {
184947
184952
  if (!path30) {
@@ -185087,6 +185092,17 @@ function parseReferenceString(str) {
185087
185092
  };
185088
185093
  }
185089
185094
  }
185095
+ const mailMatch = str.match(new RegExp(`^mail\\.(${id})\\.(\\w+)$`));
185096
+ if (mailMatch && mailMatch[1] && mailMatch[2]) {
185097
+ const attr = mailMatch[2];
185098
+ if (["host", "port", "user", "password", "from"].includes(attr)) {
185099
+ return {
185100
+ type: "mail",
185101
+ name: mailMatch[1],
185102
+ attribute: attr
185103
+ };
185104
+ }
185105
+ }
185090
185106
  const volumeMatch = str.match(new RegExp(`^volume\\.(${id})\\.(\\w+)$`));
185091
185107
  if (volumeMatch && volumeMatch[1] && volumeMatch[2]) {
185092
185108
  const attr = volumeMatch[2];
@@ -185131,7 +185147,7 @@ function parseReferenceString(str) {
185131
185147
  attribute: serviceMatch[2]
185132
185148
  };
185133
185149
  }
185134
- const knownPrefixes = ["build", "postgres", "redis", "storage", "temporal", "volume", "config", "secret", "endpoint", "service"];
185150
+ const knownPrefixes = ["build", "postgres", "redis", "storage", "temporal", "mail", "volume", "config", "secret", "endpoint", "service"];
185135
185151
  const prefixMatch = str.match(/^(\w+)\./);
185136
185152
  if (prefixMatch && knownPrefixes.includes(prefixMatch[1])) {
185137
185153
  throw new Error(`Invalid reference "\${${str}}". The prefix "${prefixMatch[1]}" is recognized but the reference format is invalid.`);
@@ -185481,6 +185497,16 @@ function parseTemporal(data) {
185481
185497
  }
185482
185498
  return result;
185483
185499
  }
185500
+ function parseMail(data) {
185501
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
185502
+ return [];
185503
+ }
185504
+ const result = [];
185505
+ for (const [name] of Object.entries(data)) {
185506
+ result.push({ name });
185507
+ }
185508
+ return result;
185509
+ }
185484
185510
  function parseConfigDevBlock(dev) {
185485
185511
  if (!dev) {
185486
185512
  return void 0;
@@ -185593,6 +185619,7 @@ async function parseConfig(hcl) {
185593
185619
  redis: parseRedis(json.redis),
185594
185620
  storage: parseStorage(json.storage),
185595
185621
  temporal: parseTemporal(json.temporal),
185622
+ mail: parseMail(json.mail),
185596
185623
  configs: parseConfigs(json.config),
185597
185624
  secrets: parseSecrets(json.secret),
185598
185625
  environments: parseEnvironments(json.environment)
@@ -188624,6 +188651,26 @@ function resolveEnvValue(value, resources, secrets, configs, servicePort, servic
188624
188651
  throw new Error(`Unknown temporal attribute: ${String(value.attribute)}`);
188625
188652
  }
188626
188653
  }
188654
+ case "mail": {
188655
+ const mail = resources.get(value.name);
188656
+ if (!mail || mail.type !== "mail") {
188657
+ throw new Error(`Mail "${value.name}" not found`);
188658
+ }
188659
+ switch (value.attribute) {
188660
+ case "host":
188661
+ return mail.host;
188662
+ case "port":
188663
+ return String(mail.port);
188664
+ case "user":
188665
+ return "specific";
188666
+ case "password":
188667
+ return "specific";
188668
+ case "from":
188669
+ return "noreply@localhost";
188670
+ default:
188671
+ throw new Error(`Unknown mail attribute: ${String(value.attribute)}`);
188672
+ }
188673
+ }
188627
188674
  case "config": {
188628
188675
  const configValue = configs.get(value.name);
188629
188676
  if (configValue === void 0) {
@@ -189688,16 +189735,144 @@ function sleep3(ms) {
189688
189735
  return new Promise((resolve10) => setTimeout(resolve10, ms));
189689
189736
  }
189690
189737
 
189738
+ // src/lib/dev/mail-manager.ts
189739
+ import * as http2 from "http";
189740
+ import * as crypto3 from "crypto";
189741
+ async function startMailServer(mail, smtpPort, apiPort) {
189742
+ const emails = [];
189743
+ const { SMTPServer } = await import("smtp-server");
189744
+ const { simpleParser } = await import("mailparser");
189745
+ const smtpServer = new SMTPServer({
189746
+ authOptional: true,
189747
+ disabledCommands: ["STARTTLS"],
189748
+ onAuth(auth, _session, callback) {
189749
+ if (auth.username === "specific" && auth.password === "specific") {
189750
+ callback(null, { user: auth.username });
189751
+ } else {
189752
+ callback(new Error("Invalid credentials"));
189753
+ }
189754
+ },
189755
+ onData(stream, session, callback) {
189756
+ let rawData = "";
189757
+ stream.on("data", (chunk) => {
189758
+ rawData += chunk.toString();
189759
+ });
189760
+ stream.on("end", async () => {
189761
+ try {
189762
+ const parsed = await simpleParser(rawData);
189763
+ const email = {
189764
+ id: crypto3.randomUUID(),
189765
+ from: parsed.from?.text ?? "",
189766
+ to: Array.isArray(parsed.to) ? parsed.to.map((addr) => addr.text) : parsed.to ? [parsed.to.text] : [],
189767
+ subject: parsed.subject ?? "(no subject)",
189768
+ text: parsed.text,
189769
+ html: typeof parsed.html === "string" ? parsed.html : void 0,
189770
+ date: parsed.date ?? /* @__PURE__ */ new Date()
189771
+ };
189772
+ emails.push(email);
189773
+ } catch {
189774
+ emails.push({
189775
+ id: crypto3.randomUUID(),
189776
+ from: session.envelope.mailFrom ? session.envelope.mailFrom.address : "",
189777
+ to: session.envelope.rcptTo.map((r) => r.address),
189778
+ subject: "(parse error)",
189779
+ text: rawData,
189780
+ date: /* @__PURE__ */ new Date()
189781
+ });
189782
+ }
189783
+ callback();
189784
+ });
189785
+ }
189786
+ });
189787
+ const httpServer = http2.createServer((req, res) => {
189788
+ res.setHeader("Access-Control-Allow-Origin", "*");
189789
+ res.setHeader("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS");
189790
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
189791
+ if (req.method === "OPTIONS") {
189792
+ res.writeHead(204);
189793
+ res.end();
189794
+ return;
189795
+ }
189796
+ const url = new URL(req.url ?? "/", `http://localhost:${apiPort}`);
189797
+ if (req.method === "GET" && url.pathname === "/api/emails") {
189798
+ const summaries = [...emails].reverse().map(({ id, from, to, subject, date }) => ({
189799
+ id,
189800
+ from,
189801
+ to,
189802
+ subject,
189803
+ date
189804
+ }));
189805
+ res.writeHead(200, { "Content-Type": "application/json" });
189806
+ res.end(JSON.stringify(summaries));
189807
+ return;
189808
+ }
189809
+ const emailMatch = url.pathname.match(/^\/api\/emails\/(.+)$/);
189810
+ if (req.method === "GET" && emailMatch) {
189811
+ const email = emails.find((e) => e.id === emailMatch[1]);
189812
+ if (email) {
189813
+ res.writeHead(200, { "Content-Type": "application/json" });
189814
+ res.end(JSON.stringify(email));
189815
+ } else {
189816
+ res.writeHead(404, { "Content-Type": "application/json" });
189817
+ res.end(JSON.stringify({ error: "Email not found" }));
189818
+ }
189819
+ return;
189820
+ }
189821
+ if (req.method === "DELETE" && url.pathname === "/api/emails") {
189822
+ emails.length = 0;
189823
+ res.writeHead(200, { "Content-Type": "application/json" });
189824
+ res.end(JSON.stringify({ ok: true }));
189825
+ return;
189826
+ }
189827
+ res.writeHead(404);
189828
+ res.end();
189829
+ });
189830
+ await new Promise((resolve10, reject) => {
189831
+ smtpServer.listen(smtpPort, "127.0.0.1", () => resolve10());
189832
+ smtpServer.on("error", reject);
189833
+ });
189834
+ await new Promise((resolve10, reject) => {
189835
+ httpServer.listen(apiPort, "127.0.0.1", () => resolve10());
189836
+ httpServer.on("error", reject);
189837
+ });
189838
+ const stop = async () => {
189839
+ await new Promise((resolve10) => {
189840
+ smtpServer.close(() => resolve10());
189841
+ });
189842
+ await new Promise((resolve10) => {
189843
+ httpServer.close(() => resolve10());
189844
+ });
189845
+ };
189846
+ const resource = {
189847
+ name: mail.name,
189848
+ type: "mail",
189849
+ port: smtpPort,
189850
+ url: `smtp://127.0.0.1:${smtpPort}`,
189851
+ host: "127.0.0.1",
189852
+ user: "",
189853
+ password: "",
189854
+ dbName: mail.name,
189855
+ stop
189856
+ };
189857
+ const mailServer = {
189858
+ smtpPort,
189859
+ apiPort,
189860
+ getEmails: () => emails,
189861
+ stop
189862
+ };
189863
+ return { resource, mailServer };
189864
+ }
189865
+
189691
189866
  // src/lib/dev/drizzle-gateway-manager.ts
189692
189867
  import * as net3 from "net";
189693
189868
  import * as fs18 from "fs";
189694
189869
  import * as path15 from "path";
189695
189870
  import { spawn as spawn4 } from "child_process";
189696
- import { randomUUID } from "crypto";
189871
+ import { randomUUID as randomUUID2 } from "crypto";
189697
189872
  function generateStoreJson(postgresInstances) {
189698
- const storeId = randomUUID();
189873
+ const storeId = randomUUID2();
189699
189874
  const slots = postgresInstances.map((pg) => {
189700
- const slotId = randomUUID();
189875
+ const slotId = randomUUID2();
189701
189876
  return [
189702
189877
  slotId,
189703
189878
  {
@@ -190215,7 +190390,7 @@ async function stopProcess4(proc) {
190215
190390
 
190216
190391
  // src/lib/dev/resource-starter.ts
190217
190392
  function findRequiredResources(service) {
190218
- const required = { postgres: [], redis: [], storage: [], temporal: [] };
190393
+ const required = { postgres: [], redis: [], storage: [], temporal: [], mail: [] };
190219
190394
  if (service.env) {
190220
190395
  for (const value of Object.values(service.env)) {
190221
190396
  if (typeof value !== "object" || value === null) continue;
@@ -190229,6 +190404,8 @@ function findRequiredResources(service) {
190229
190404
  required.storage.push(ref.name);
190230
190405
  } else if (ref.type === "temporal" && !required.temporal.includes(ref.name)) {
190231
190406
  required.temporal.push(ref.name);
190407
+ } else if (ref.type === "mail" && !required.mail.includes(ref.name)) {
190408
+ required.mail.push(ref.name);
190232
190409
  }
190233
190410
  }
190234
190411
  }
@@ -190250,26 +190427,30 @@ async function startResources(options2) {
190250
190427
  });
190251
190428
  const resources = /* @__PURE__ */ new Map();
190252
190429
  const electric = /* @__PURE__ */ new Map();
190430
+ const mailServers = /* @__PURE__ */ new Map();
190253
190431
  const startedResources = [];
190254
190432
  const startedElectric = [];
190255
190433
  let postgresConfigs;
190256
190434
  let redisConfigs;
190257
190435
  let storageConfigs;
190258
190436
  let temporalConfigs;
190437
+ let mailConfigs;
190259
190438
  if (selection.mode === "all") {
190260
190439
  postgresConfigs = config.postgres;
190261
190440
  redisConfigs = config.redis;
190262
190441
  storageConfigs = config.storage;
190263
190442
  temporalConfigs = config.temporal;
190443
+ mailConfigs = config.mail;
190264
190444
  } else {
190265
190445
  postgresConfigs = config.postgres.filter((p) => selection.postgres.includes(p.name));
190266
190446
  redisConfigs = config.redis.filter((r) => selection.redis.includes(r.name));
190267
190447
  storageConfigs = config.storage.filter((s) => selection.storage.includes(s.name));
190268
190448
  temporalConfigs = config.temporal.filter((t) => selection.temporal.includes(t.name));
190449
+ mailConfigs = config.mail.filter((m) => selection.mail.includes(m.name));
190269
190450
  }
190270
190451
  for (const pg of postgresConfigs) {
190271
190452
  if (signal?.cancelled) {
190272
- return { resources, electric, startedResources, startedElectric, cancelled: true };
190453
+ return { resources, electric, mail: mailServers, startedResources, startedElectric, cancelled: true };
190273
190454
  }
190274
190455
  const port = portAllocator.allocate(`postgres:${pg.name}`);
190275
190456
  log(`Starting postgres "${pg.name}" on port ${port}`);
@@ -190303,7 +190484,7 @@ async function startResources(options2) {
190303
190484
  }
190304
190485
  for (const redis of redisConfigs) {
190305
190486
  if (signal?.cancelled) {
190306
- return { resources, electric, startedResources, startedElectric, cancelled: true };
190487
+ return { resources, electric, mail: mailServers, startedResources, startedElectric, cancelled: true };
190307
190488
  }
190308
190489
  const port = portAllocator.allocate(`redis:${redis.name}`);
190309
190490
  log(`Starting redis "${redis.name}" on port ${port}`);
@@ -190327,7 +190508,7 @@ async function startResources(options2) {
190327
190508
  }
190328
190509
  for (const storage of storageConfigs) {
190329
190510
  if (signal?.cancelled) {
190330
- return { resources, electric, startedResources, startedElectric, cancelled: true };
190511
+ return { resources, electric, mail: mailServers, startedResources, startedElectric, cancelled: true };
190331
190512
  }
190332
190513
  const port = portAllocator.allocate(`storage:${storage.name}`);
190333
190514
  log(`Starting storage "${storage.name}" on port ${port}`);
@@ -190353,7 +190534,7 @@ async function startResources(options2) {
190353
190534
  }
190354
190535
  if (temporalConfigs.length > 0) {
190355
190536
  if (signal?.cancelled) {
190356
- return { resources, electric, startedResources, startedElectric, cancelled: true };
190537
+ return { resources, electric, mail: mailServers, startedResources, startedElectric, cancelled: true };
190357
190538
  }
190358
190539
  const grpcPort = portAllocator.allocate("temporal-grpc");
190359
190540
  const uiPort = portAllocator.allocate("temporal-ui");
@@ -190370,11 +190551,36 @@ async function startResources(options2) {
190370
190551
  log(`Temporal namespace "${instance.name}" ready`);
190371
190552
  }
190372
190553
  }
190554
+ for (const mail of mailConfigs) {
190555
+ if (signal?.cancelled) {
190556
+ return { resources, electric, mail: mailServers, startedResources, startedElectric, cancelled: true };
190557
+ }
190558
+ const smtpPort = portAllocator.allocate(`mail-smtp:${mail.name}`);
190559
+ const mailApiPort = portAllocator.allocate(`mail-api:${mail.name}`);
190560
+ log(`Starting mail "${mail.name}" on SMTP port ${smtpPort} (API: ${mailApiPort})`);
190561
+ callbacks.onResourceStarting?.(mail.name, "mail");
190562
+ const { resource, mailServer } = await startMailServer(mail, smtpPort, mailApiPort);
190563
+ resources.set(mail.name, resource);
190564
+ startedResources.push(resource);
190565
+ mailServers.set(mail.name, mailServer);
190566
+ callbacks.onResourceReady?.(mail.name, resource);
190567
+ log(`Mail "${mail.name}" ready`);
190568
+ await stateManager.registerDatabase(mail.name, {
190569
+ engine: "mail",
190570
+ port: smtpPort,
190571
+ host: "127.0.0.1",
190572
+ user: "",
190573
+ password: "",
190574
+ dbName: mail.name,
190575
+ url: `smtp://127.0.0.1:${smtpPort}`,
190576
+ mailApiPort
190577
+ });
190578
+ }
190373
190579
  if (shouldStartElectric) {
190374
190580
  const syncDatabases = detectSyncDatabases(config);
190375
190581
  for (const pgName of syncDatabases) {
190376
190582
  if (signal?.cancelled) {
190377
- return { resources, electric, startedResources, startedElectric, cancelled: true };
190583
+ return { resources, electric, mail: mailServers, startedResources, startedElectric, cancelled: true };
190378
190584
  }
190379
190585
  const pg = resources.get(pgName);
190380
190586
  if (!pg || pg.type !== "postgres") continue;
@@ -190394,7 +190600,7 @@ async function startResources(options2) {
190394
190600
  log(`Electric sync for "${pgName}" ready at ${electricInstance.url}`);
190395
190601
  }
190396
190602
  }
190397
- return { resources, electric, startedResources, startedElectric, cancelled: false };
190603
+ return { resources, electric, mail: mailServers, startedResources, startedElectric, cancelled: false };
190398
190604
  }
190399
190605
 
190400
190606
  // src/lib/dev/config-watcher.ts
@@ -191051,12 +191257,14 @@ function SecretInput({ secretName, onSubmit, onCancel }) {
191051
191257
  // src/lib/ui/ConfigInput.tsx
191052
191258
  import React5, { useState as useState4 } from "react";
191053
191259
  import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
191054
- function ConfigInput({ configName, onSubmit, onCancel }) {
191260
+ function ConfigInput({ configName, defaultValue, onSubmit, onCancel }) {
191055
191261
  const [value, setValue] = useState4("");
191056
191262
  useInput3((input, key) => {
191057
191263
  if (key.return) {
191058
191264
  if (value.trim() !== "") {
191059
191265
  onSubmit(value);
191266
+ } else if (defaultValue !== void 0) {
191267
+ onSubmit(defaultValue);
191060
191268
  }
191061
191269
  } else if (key.escape) {
191062
191270
  onCancel();
@@ -191066,7 +191274,7 @@ function ConfigInput({ configName, onSubmit, onCancel }) {
191066
191274
  setValue((prev) => prev + input);
191067
191275
  }
191068
191276
  });
191069
- return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React5.createElement(Text5, null, "Enter value for config ", /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, configName), ":"), /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, "> "), /* @__PURE__ */ React5.createElement(Text5, null, value), /* @__PURE__ */ React5.createElement(Text5, { color: "gray" }, "|")), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "(Press Enter to save, Esc to cancel)"));
191277
+ return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React5.createElement(Text5, null, "Enter value for config ", /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, configName), defaultValue !== void 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " (default: ", defaultValue, ")"), ":"), /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, "> "), /* @__PURE__ */ React5.createElement(Text5, null, value), /* @__PURE__ */ React5.createElement(Text5, { color: "gray" }, "|")), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, defaultValue !== void 0 ? "(Press Enter to accept default, type to override, Esc to cancel)" : "(Press Enter to save, Esc to cancel)"));
191070
191278
  }
191071
191279
 
191072
191280
  // src/commands/dev.tsx
@@ -191472,6 +191680,7 @@ function DevUI({ instanceKey, tunnelEnabled }) {
191472
191680
  const resourceStatus = /* @__PURE__ */ new Map();
191473
191681
  const syncDatabases = detectSyncDatabases(config2);
191474
191682
  let resources2;
191683
+ let mailServers = /* @__PURE__ */ new Map();
191475
191684
  try {
191476
191685
  const result = await startResources({
191477
191686
  config: config2,
@@ -191519,6 +191728,7 @@ function DevUI({ instanceKey, tunnelEnabled }) {
191519
191728
  });
191520
191729
  if (result.cancelled) return;
191521
191730
  resources2 = result.resources;
191731
+ mailServers = result.mail;
191522
191732
  startedResources.push(...result.startedResources);
191523
191733
  } catch (err) {
191524
191734
  const errorMsg = `Failed to start resources: ${err instanceof Error ? err.message : String(err)}`;
@@ -191913,6 +192123,9 @@ Add them to the config block in specific.local`);
191913
192123
  }
191914
192124
  const projectId = hasProjectId() ? readProjectId() : void 0;
191915
192125
  const hasTemporal = config2.temporal.length > 0;
192126
+ const hasMail = config2.mail.length > 0;
192127
+ const firstMailServer = mailServers.size > 0 ? [...mailServers.values()][0] : void 0;
192128
+ const mailApiUrl = firstMailServer ? `http://127.0.0.1:${firstMailServer.apiPort}` : void 0;
191916
192129
  const getState = () => ({
191917
192130
  status: "running",
191918
192131
  services: config2.services.filter((svc) => runningServicePorts.has(svc.name) || svc.serve).map((svc) => ({
@@ -191929,7 +192142,9 @@ Add them to the config block in specific.local`);
191929
192142
  syncEnabled: r.type === "postgres" && syncDatabases.has(name)
191930
192143
  })),
191931
192144
  projectId,
191932
- hasTemporal
192145
+ hasTemporal,
192146
+ hasMail,
192147
+ mailApiUrl
191933
192148
  });
191934
192149
  const adminServer = await startAdminServer(getState);
191935
192150
  adminServerRef.current = adminServer;
@@ -193563,6 +193778,7 @@ ${errorMsg}`
193563
193778
  {
193564
193779
  key: currentConfig,
193565
193780
  configName: currentConfig,
193781
+ defaultValue: config.configs?.find((c) => c.name === currentConfig)?.default,
193566
193782
  onSubmit: handleConfigSubmit,
193567
193783
  onCancel: handleConfigCancel
193568
193784
  }
@@ -194385,7 +194601,7 @@ function compareVersions(a, b) {
194385
194601
  return 0;
194386
194602
  }
194387
194603
  async function checkForUpdate() {
194388
- const currentVersion = "0.1.67";
194604
+ const currentVersion = "0.1.68";
194389
194605
  const response = await fetch(`${BINARIES_BASE_URL}/latest`);
194390
194606
  if (!response.ok) {
194391
194607
  throw new Error(`Failed to check for updates: HTTP ${response.status}`);
@@ -194583,7 +194799,7 @@ function updateCommand() {
194583
194799
  var program = new Command();
194584
194800
  var env = "production";
194585
194801
  var envLabel = env !== "production" ? `[${env.toUpperCase()}] ` : "";
194586
- program.name("specific").description(`${envLabel}Infrastructure-as-code for coding agents`).version("0.1.67").enablePositionalOptions();
194802
+ program.name("specific").description(`${envLabel}Infrastructure-as-code for coding agents`).version("0.1.68").enablePositionalOptions();
194587
194803
  program.command("init").description("Initialize project for use with a coding agent").option("--agent <name...>", "Agents to configure (cursor, claude, codex, other)").action((options2) => initCommand(options2));
194588
194804
  program.command("docs [topic]").description("Fetch LLM-optimized documentation").action(docsCommand);
194589
194805
  program.command("check").description("Validate specific.hcl configuration").action(checkCommand);
@@ -27,6 +27,9 @@ A full development environment can be started with `specific dev`. To deploy any
27
27
  <!-- beta:temporal -->
28
28
  - [Temporal](/temporal): managed durable workflow engine for background tasks, AI agents, cron jobs and more.
29
29
  <!-- /beta:temporal -->
30
+ <!-- beta:mail -->
31
+ - [Mail](/mail): managed email sending via SMTP for transactional emails.
32
+ <!-- /beta:mail -->
30
33
 
31
34
  ## Common integrations
32
35
 
@@ -0,0 +1,66 @@
1
+ # Mail
2
+
3
+ Managed email sending via SMTP.
4
+
5
+ ```hcl
6
+ mail "notifications" {}
7
+ ```
8
+
9
+ Reference mail attributes in env blocks:
10
+
11
+ ```hcl
12
+ service "api" {
13
+ build = build.api
14
+ command = "./api"
15
+
16
+ endpoint {
17
+ public = true
18
+ }
19
+
20
+ env = {
21
+ SMTP_HOST = mail.notifications.host
22
+ SMTP_PORT = mail.notifications.port
23
+ SMTP_USER = mail.notifications.user
24
+ SMTP_PASSWORD = mail.notifications.password
25
+ MAIL_FROM = mail.notifications.from
26
+ }
27
+ }
28
+
29
+ mail "notifications" {}
30
+ ```
31
+
32
+ ## Available mail attributes
33
+
34
+ - `host` - SMTP server hostname
35
+ - `port` - SMTP server port
36
+ - `user` - SMTP authentication username
37
+ - `password` - SMTP authentication password
38
+ - `from` - Default sender address
39
+
40
+ ## Development
41
+
42
+ In development, emails are captured by a local SMTP server and displayed in the admin dashboard under the Mail tab. No emails are actually sent.
43
+
44
+ ## Example using Nodemailer (JavaScript)
45
+
46
+ ```javascript
47
+ import nodemailer from "nodemailer";
48
+
49
+ const transporter = nodemailer.createTransport({
50
+ host: process.env.SMTP_HOST,
51
+ port: Number(process.env.SMTP_PORT),
52
+ secure: false,
53
+ auth: {
54
+ user: process.env.SMTP_USER,
55
+ pass: process.env.SMTP_PASSWORD,
56
+ },
57
+ });
58
+
59
+ await transporter.sendMail({
60
+ from: process.env.MAIL_FROM,
61
+ to: "user@example.com",
62
+ subject: "Welcome!",
63
+ text: "Thanks for signing up.",
64
+ html: "<p>Thanks for signing up.</p>",
65
+ });
66
+ ```
@@ -111,7 +111,7 @@ function trackEvent(event, properties) {
111
111
  event,
112
112
  properties: {
113
113
  ...properties,
114
- cli_version: "0.1.67",
114
+ cli_version: "0.1.68",
115
115
  platform: process.platform,
116
116
  node_version: process.version,
117
117
  project_id: getProjectId(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/cli",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "description": "CLI for Specific infrastructure-as-code",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -44,12 +44,14 @@
44
44
  "http-proxy": "^1.18.1",
45
45
  "ink": "^6.5.1",
46
46
  "ink-spinner": "^5.0.0",
47
+ "mailparser": "^3.9.3",
47
48
  "node-forge": "^1.3.1",
48
49
  "open": "^11.0.0",
49
50
  "posthog-node": "^5.24.1",
50
51
  "random-word-slugs": "^0.1.7",
51
52
  "react": "^19.0.0",
52
53
  "s3rver": "^3.7.1",
54
+ "smtp-server": "^3.18.1",
53
55
  "tar-vern": "^1.3.0"
54
56
  },
55
57
  "devDependencies": {
@@ -57,9 +59,11 @@
57
59
  "@specific/tunnel-client": "file:../tunnel/client",
58
60
  "@types/babel__code-frame": "^7.27.0",
59
61
  "@types/http-proxy": "^1.17.17",
62
+ "@types/mailparser": "^3.4.6",
60
63
  "@types/node": "^25.0.1",
61
64
  "@types/node-forge": "^1.3.11",
62
65
  "@types/react": "^19.2.7",
66
+ "@types/smtp-server": "^3.5.12",
63
67
  "esbuild": "^0.24.0",
64
68
  "tsx": "^4.21.0",
65
69
  "typescript": "^5.9.3"
@@ -1 +0,0 @@
1
- (globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,12283,e=>{"use strict";var t=e.i(90795),r=e.i(59369);let a=(0,r.createContext)(void 0);function s({children:e}){let[s,n]=(0,r.useState)(null),[l,o]=(0,r.useState)(null),[i,c]=(0,r.useState)(!1),u=(0,r.useMemo)(()=>(function(){let e=window.location.hostname,t=".local.spcf.app";if("local.spcf.app"===e)return"default";if(e.endsWith(t)){let r=e.slice(0,-t.length);if(r&&!r.includes("."))return r}return null})(),[]),m=(0,r.useMemo)(()=>u?"default"===u?"https://__drizzle_gateway.local.spcf.app":`https://__drizzle_gateway.${u}.local.spcf.app`:null,[u]);(0,r.useEffect)(()=>{async function e(){try{let e=await fetch("/api/state");if(!e.ok)throw Error("Failed to fetch state");let t=await e.json();n(t),c(!0),o(null)}catch(e){o(e instanceof Error?e.message:"Unknown error"),c(!1)}}e();let t=setInterval(e,2e3);return()=>clearInterval(t)},[]);let d=s?.resources.some(e=>"postgres"===e.type)??!1,h=s?.hasTemporal??!1,p=(0,r.useMemo)(()=>u?"default"===u?"https://__temporal.local.spcf.app":`https://__temporal.${u}.local.spcf.app`:null,[u]),f=s?.projectId??null,v=(0,r.useMemo)(()=>({state:s,error:l,connected:i,instanceKey:u,hasDatabases:d,drizzleGatewayUrl:m,hasTemporal:h,temporalUiUrl:p,projectId:f}),[s,l,i,u,d,m,h,p,f]);return(0,t.jsx)(a.Provider,{value:v,children:e})}function n(){let e=(0,r.useContext)(a);if(void 0===e)throw Error("useDevState must be used within a DevStateProvider");return e}e.s(["DevStateProvider",()=>s,"useDevState",()=>n],12283)},70932,e=>{"use strict";var t=e.i(59369),r=(e,t,r,a,s,n,l,o)=>{let i=document.documentElement,c=["light","dark"];function u(t){var r;(Array.isArray(e)?e:[e]).forEach(e=>{let r="class"===e,a=r&&n?s.map(e=>n[e]||e):s;r?(i.classList.remove(...a),i.classList.add(n&&n[t]?n[t]:t)):i.setAttribute(e,t)}),r=t,o&&c.includes(r)&&(i.style.colorScheme=r)}if(a)u(a);else try{let e=localStorage.getItem(t)||r,a=l&&"system"===e?window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light":e;u(a)}catch(e){}},a=["light","dark"],s="(prefers-color-scheme: dark)",n="u"<typeof window,l=t.createContext(void 0),o={setTheme:e=>{},themes:[]},i=()=>{var e;return null!=(e=t.useContext(l))?e:o},c=e=>t.useContext(l)?t.createElement(t.Fragment,null,e.children):t.createElement(m,{...e}),u=["light","dark"],m=({forcedTheme:e,disableTransitionOnChange:r=!1,enableSystem:n=!0,enableColorScheme:o=!0,storageKey:i="theme",themes:c=u,defaultTheme:m=n?"system":"light",attribute:v="data-theme",value:y,children:g,nonce:w,scriptProps:b})=>{let[S,T]=t.useState(()=>h(i,m)),[E,C]=t.useState(()=>"system"===S?f():S),k=y?Object.values(y):c,P=t.useCallback(e=>{let t=e;if(!t)return;"system"===e&&n&&(t=f());let s=y?y[t]:t,l=r?p(w):null,i=document.documentElement,c=e=>{"class"===e?(i.classList.remove(...k),s&&i.classList.add(s)):e.startsWith("data-")&&(s?i.setAttribute(e,s):i.removeAttribute(e))};if(Array.isArray(v)?v.forEach(c):c(v),o){let e=a.includes(m)?m:null,r=a.includes(t)?t:e;i.style.colorScheme=r}null==l||l()},[w]),_=t.useCallback(e=>{let t="function"==typeof e?e(S):e;T(t);try{localStorage.setItem(i,t)}catch(e){}},[S]),A=t.useCallback(t=>{C(f(t)),"system"===S&&n&&!e&&P("system")},[S,e]);t.useEffect(()=>{let e=window.matchMedia(s);return e.addListener(A),A(e),()=>e.removeListener(A)},[A]),t.useEffect(()=>{let e=e=>{e.key===i&&(e.newValue?T(e.newValue):_(m))};return window.addEventListener("storage",e),()=>window.removeEventListener("storage",e)},[_]),t.useEffect(()=>{P(null!=e?e:S)},[e,S]);let L=t.useMemo(()=>({theme:S,setTheme:_,forcedTheme:e,resolvedTheme:"system"===S?E:S,themes:n?[...c,"system"]:c,systemTheme:n?E:void 0}),[S,_,e,E,n,c]);return t.createElement(l.Provider,{value:L},t.createElement(d,{forcedTheme:e,storageKey:i,attribute:v,enableSystem:n,enableColorScheme:o,defaultTheme:m,value:y,themes:c,nonce:w,scriptProps:b}),g)},d=t.memo(({forcedTheme:e,storageKey:a,attribute:s,enableSystem:n,enableColorScheme:l,defaultTheme:o,value:i,themes:c,nonce:u,scriptProps:m})=>{let d=JSON.stringify([s,a,o,e,c,i,n,l]).slice(1,-1);return t.createElement("script",{...m,suppressHydrationWarning:!0,nonce:"u"<typeof window?u:"",dangerouslySetInnerHTML:{__html:`(${r.toString()})(${d})`}})}),h=(e,t)=>{let r;if(!n){try{r=localStorage.getItem(e)||void 0}catch(e){}return r||t}},p=e=>{let t=document.createElement("style");return e&&t.setAttribute("nonce",e),t.appendChild(document.createTextNode("*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(t),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(t)},1)}},f=e=>(e||(e=window.matchMedia(s)),e.matches?"dark":"light");e.s(["ThemeProvider",()=>c,"useTheme",()=>i])},49311,e=>{"use strict";var t=e.i(90795),r=e.i(70932);function a({children:e,...a}){return(0,t.jsx)(r.ThemeProvider,{...a,children:e})}e.s(["ThemeProvider",()=>a])}]);