@dutodev/cli 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -145,9 +145,14 @@ Com os túneis no ar, o terminal mostra cada túnel (URL, destino local, requisi
145
145
  | `m` | Mostra o QR code do túnel selecionado, para abrir no celular |
146
146
  | `f` | Filtra por túnel, método, caminho ou status |
147
147
  | `↵` | Detalhes da requisição: cabeçalhos e o começo dos corpos |
148
+ | `r` | No detalhe, reenvia a requisição ao serviço local |
148
149
  | `tab`, `↑↓` | Alterna entre túneis e requisições e move a seleção |
149
150
  | `q` ou Ctrl+C | Fecha os túneis e sai |
150
151
 
152
+ **Reenviar uma requisição.** No detalhe (`↵`), a tecla `r` manda de novo ao serviço local o mesmo método, caminho, cabeçalhos e corpo que o app recebeu, já com as regras `host:` e `headers:` do túnel aplicadas e sem as credenciais do próprio Duto. O reenvio entra na lista marcado com `↻`, a tela pula para o detalhe dele (`↻ reenvio de #N`) e `esc` volta. Serve para depurar webhooks sem esperar o serviço externo mandar de novo; em especial, uma requisição que ficou com `503` porque o serviço local estava fora do ar pode ser reenviada assim que ele subir. Como os bytes são idênticos, assinaturas de webhook continuam válidas. Já apps que recusam eventos antigos (o Stripe tolera 5 minutos por padrão) vão rejeitar o reenvio depois dessa janela.
153
+
154
+ Não dá para reenviar WebSocket, requisições barradas pela senha do túnel, corpos binários, corpos acima de 64 KB, requisições cujo corpo não terminou de chegar ou quando o túnel já não está aberto; nesses casos uma linha explica o motivo. O reenvio não existe no log simples (sem terminal interativo).
155
+
151
156
  Se o túnel está no ar mas nada responde na porta local, quem abrir o endereço no navegador vê uma página do Duto que volta sozinha quando o serviço sobe. Ela espera por uma conexão só (SSE): a CLI checa a porta na sua máquina e avisa a página assim que o serviço responde, sem recarregar de tempos em tempos. Clientes que não são navegador (`curl`, APIs) recebem a mesma resposta em texto. Em ambos os casos o status é `503` com `Retry-After: 3`. Os caminhos `/__duto/…` são reservados para isso e nunca chegam ao seu app.
152
157
 
153
158
  Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log simples. No Windows a tela cheia vem desligada; use `--ui` para tentar.
@@ -84,12 +84,16 @@ function Detail({ record }) {
84
84
  record.tunnel
85
85
  ] })
86
86
  ] }),
87
+ record.replayOf !== void 0 && /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
88
+ "\u21BB reenvio de #",
89
+ record.replayOf
90
+ ] }),
87
91
  record.error && /* @__PURE__ */ jsx(Text, { color: C.err, children: record.error }),
88
- /* @__PURE__ */ jsx(Section, { title: "Requisi\xE7\xE3o", headers: record.reqHeaders, body: record.reqBody, truncated: record.truncated }),
89
- /* @__PURE__ */ jsx(Section, { title: "Resposta", headers: record.resHeaders, body: record.resBody, truncated: record.truncated }),
92
+ /* @__PURE__ */ jsx(Section, { title: "Requisi\xE7\xE3o", headers: record.reqHeaders, body: record.reqBody, truncated: record.reqTruncated }),
93
+ /* @__PURE__ */ jsx(Section, { title: "Resposta", headers: record.resHeaders, body: record.resBody, truncated: record.resTruncated }),
90
94
  /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
91
95
  "\n",
92
- "esc volta para a lista"
96
+ "r reenvia \xB7 esc volta para a lista"
93
97
  ] })
94
98
  ] });
95
99
  }
@@ -189,7 +193,7 @@ function RequestList(props) {
189
193
  const start = Math.max(0, Math.min(selected - Math.floor(height / 2), records.length - height));
190
194
  const visible = records.slice(start, start + height);
191
195
  const accessW = Math.max(0, ...visible.map((r) => (accessLabel(r.access)?.text.length ?? -1) + 1));
192
- const pathW = Math.max(10, width - 52 - accessW);
196
+ const pathW = Math.max(10, width - 54 - accessW);
193
197
  const title = props.filter ? `REQUISI\xC7\xD5ES \xB7 filtro: ${props.filter} ` : "REQUISI\xC7\xD5ES ";
194
198
  return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
195
199
  /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: title + "\u2500".repeat(Math.max(0, width - title.length)) }),
@@ -204,6 +208,7 @@ function RequestList(props) {
204
208
  " ",
205
209
  /* @__PURE__ */ jsx4(Text4, { color: tunnelColor(props.tunnelIndex(r.tunnel)), children: fit(r.tunnel, 9) }),
206
210
  " ",
211
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: r.replayOf !== void 0 ? "\u21BB " : " " }),
207
212
  /* @__PURE__ */ jsx4(Text4, { bold: true, children: fit(method, 5) }),
208
213
  fit(r.path, pathW),
209
214
  " ",
@@ -316,6 +321,9 @@ function App({ session, version, system, onExit }) {
316
321
  useEffect(() => {
317
322
  if (selReq >= records.length) setSelReq(Math.max(0, records.length - 1));
318
323
  }, [records.length, selReq]);
324
+ useEffect(() => {
325
+ if (mode.kind === "detail" && !all.some((r) => r.id === mode.id)) setMode({ kind: "list" });
326
+ }, [mode, all]);
319
327
  useEffect(() => {
320
328
  if (!message) return;
321
329
  const t = setTimeout(() => setMessage(null), 4e3);
@@ -396,7 +404,12 @@ function App({ session, version, system, onExit }) {
396
404
  return;
397
405
  }
398
406
  if (mode.kind === "detail") {
399
- if (key.escape || key.return || input === "q") setMode({ kind: "list" });
407
+ if (key.escape || key.return || input === "q") return setMode({ kind: "list" });
408
+ if (input === "r") {
409
+ const res = session.replay(mode.id);
410
+ if (res.ok) setMode({ kind: "detail", id: res.id });
411
+ else setMessage({ text: res.reason, tone: "warn" });
412
+ }
400
413
  return;
401
414
  }
402
415
  if (mode.kind === "qr") {
@@ -419,7 +432,7 @@ function App({ session, version, system, onExit }) {
419
432
  else setSelReq((i) => Math.min(Math.max(0, i + d), Math.max(0, records.length - 1)));
420
433
  return;
421
434
  }
422
- if (key.return && focus === "requests" && records[selReq]) return setMode({ kind: "detail" });
435
+ if (key.return && focus === "requests" && records[selReq]) return setMode({ kind: "detail", id: records[selReq].id });
423
436
  if (key.escape && filter) return setFilter("");
424
437
  if (input === "c") return void act("copy");
425
438
  if (input === "o") return void act("open");
@@ -430,7 +443,7 @@ function App({ session, version, system, onExit }) {
430
443
  const live = state.tunnels.filter((t) => t.status === "live").length;
431
444
  const conn = state.connection === "online" ? { text: "conectado \u25CF", color: C.signal } : state.connection === "connecting" ? { text: "conectando\u2026", color: C.muted } : { text: "reconectando\u2026", color: C.warn };
432
445
  const listHeight = Math.max(3, rows - state.tunnels.length - 9);
433
- const selected = records[selReq];
446
+ const detailRecord = mode.kind === "detail" ? all.find((r) => r.id === mode.id) : void 0;
434
447
  const qrTunnel = mode.kind === "qr" ? state.tunnels.find((t) => t.key === mode.key) : void 0;
435
448
  return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", width, children: [
436
449
  /* @__PURE__ */ jsxs6(Box5, { justifyContent: "space-between", children: [
@@ -457,7 +470,7 @@ function App({ session, version, system, onExit }) {
457
470
  /* @__PURE__ */ jsx6(Text6, { children: " " }),
458
471
  /* @__PURE__ */ jsx6(TunnelTable, { tunnels: state.tunnels, selected: selTun, focused: focus === "tunnels" }),
459
472
  /* @__PURE__ */ jsx6(Text6, { children: " " }),
460
- mode.kind === "detail" && selected ? /* @__PURE__ */ jsx6(Detail, { record: selected }) : mode.kind === "qr" && qrTunnel ? /* @__PURE__ */ jsx6(QrView, { tunnel: qrTunnel, link: mode.link, now }) : /* @__PURE__ */ jsx6(RequestList, { records, tunnelIndex, selected: selReq, focused: focus === "requests", height: listHeight, width, filter }),
473
+ mode.kind === "detail" && detailRecord ? /* @__PURE__ */ jsx6(Detail, { record: detailRecord }) : mode.kind === "qr" && qrTunnel ? /* @__PURE__ */ jsx6(QrView, { tunnel: qrTunnel, link: mode.link, now }) : /* @__PURE__ */ jsx6(RequestList, { records, tunnelIndex, selected: selReq, focused: focus === "requests", height: listHeight, width, filter }),
461
474
  /* @__PURE__ */ jsx6(Text6, { children: " " }),
462
475
  mode.kind === "filter" && /* @__PURE__ */ jsx6(Prompt, { label: "Filtrar (t\xFAnel, m\xE9todo, caminho ou status):", value: mode.value, hint: "\u21B5 aplica \xB7 esc cancela" }),
463
476
  mode.kind === "port" && /* @__PURE__ */ jsx6(Prompt, { label: "Novo t\xFAnel \u2014 porta local:", value: mode.value, hint: "\u21B5 continua \xB7 esc cancela" }),
package/dist/index.js CHANGED
@@ -787,7 +787,29 @@ ${o.error ? `<p class="error" role="alert">Usu\xE1rio ou senha incorretos.</p>
787
787
  );
788
788
  }
789
789
 
790
+ // src/session/replay.ts
791
+ function replayBlock(r) {
792
+ if (!r) return "Essa requisi\xE7\xE3o j\xE1 saiu da lista.";
793
+ if (r.websocket) return "WebSocket n\xE3o d\xE1 para reenviar.";
794
+ if (r.noReplay) return r.noReplay;
795
+ if (r.access?.kind === "denied") return "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
796
+ if (!r.reqComplete) return "A requisi\xE7\xE3o ainda n\xE3o chegou inteira.";
797
+ if (r.reqTruncated) return "Corpo acima de 64 KB; n\xE3o d\xE1 para reenviar.";
798
+ if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Corpo bin\xE1rio n\xE3o \xE9 guardado; n\xE3o d\xE1 para reenviar.";
799
+ return null;
800
+ }
801
+ var DROP = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
802
+ function replayHeaders(r) {
803
+ const out = {};
804
+ for (const [k, v] of Object.entries(r.reqHeaders)) if (!DROP.has(k.toLowerCase())) out[k] = v;
805
+ const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
806
+ if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
807
+ return out;
808
+ }
809
+
790
810
  // src/session/proxy.ts
811
+ var DENIED_NO_REPLAY = "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
812
+ var PREFLIGHT_NO_REPLAY = "Preflight de CORS n\xE3o d\xE1 para reenviar.";
791
813
  var TEXT = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
792
814
  var MAX_BODY = 64 * 1024;
793
815
  var LOCAL_AGENT = new Agent({ keepAlive: false });
@@ -805,8 +827,11 @@ var Capture = class {
805
827
  max;
806
828
  chunks = [];
807
829
  size = 0;
830
+ /** Bytes vistos, capturados ou não. */
831
+ total = 0;
808
832
  truncated = false;
809
833
  push(c) {
834
+ this.total += c.length;
810
835
  if (!this.enabled) return;
811
836
  const room = this.max - this.size;
812
837
  if (room <= 0) {
@@ -818,6 +843,9 @@ var Capture = class {
818
843
  this.chunks.push(part);
819
844
  this.size += part.length;
820
845
  }
846
+ raw() {
847
+ return this.enabled && this.size > 0 ? Buffer.concat(this.chunks) : null;
848
+ }
821
849
  text() {
822
850
  return this.enabled && this.size > 0 ? Buffer.concat(this.chunks).toString("utf8") : null;
823
851
  }
@@ -986,7 +1014,7 @@ async function startProxy(o) {
986
1014
  await new Promise((r) => setTimeout(r, 50));
987
1015
  }
988
1016
  }
989
- function record(req, route, headers, websocket, access) {
1017
+ function record(req, route, headers, websocket, access, noReplay) {
990
1018
  const id = nextId++;
991
1019
  const started = Date.now();
992
1020
  o.store.add({
@@ -1003,11 +1031,77 @@ async function startProxy(o) {
1003
1031
  resHeaders: {},
1004
1032
  reqBody: null,
1005
1033
  resBody: null,
1034
+ reqRaw: null,
1035
+ reqBodyBytes: 0,
1036
+ reqTruncated: false,
1037
+ reqComplete: false,
1006
1038
  truncated: false,
1007
- ...access && { access }
1039
+ resTruncated: false,
1040
+ ...access && { access },
1041
+ ...noReplay && { noReplay }
1008
1042
  });
1009
1043
  return { id, started };
1010
1044
  }
1045
+ function trackResponse(id, started, up, reqCap) {
1046
+ const resCap = new Capture(TEXT.test(up.headers["content-type"] ?? ""), max);
1047
+ let bytes = 0;
1048
+ up.on("data", (c) => {
1049
+ bytes += c.length;
1050
+ resCap.push(c);
1051
+ });
1052
+ up.on(
1053
+ "end",
1054
+ () => o.store.update(id, {
1055
+ status: up.statusCode ?? null,
1056
+ durationMs: Date.now() - started,
1057
+ bytes,
1058
+ resHeaders: flat(up.headers),
1059
+ resBody: resCap.text(),
1060
+ truncated: !!reqCap?.truncated || resCap.truncated,
1061
+ resTruncated: resCap.truncated,
1062
+ ...reqCap && { reqBody: reqCap.text(), reqRaw: reqCap.truncated ? null : reqCap.raw(), reqBodyBytes: reqCap.total, reqTruncated: reqCap.truncated }
1063
+ })
1064
+ );
1065
+ }
1066
+ function replay(id) {
1067
+ const r = o.store.get(id);
1068
+ const blocked = replayBlock(r);
1069
+ if (blocked || !r) return { ok: false, reason: blocked ?? "Essa requisi\xE7\xE3o j\xE1 saiu da lista." };
1070
+ const route = [...o.routes.values()].find((x) => x.tunnel === r.tunnel);
1071
+ if (!route) return { ok: false, reason: `O t\xFAnel ${r.tunnel} n\xE3o est\xE1 aberto nesta sess\xE3o.` };
1072
+ const newId = nextId++;
1073
+ const started = Date.now();
1074
+ const headers = replayHeaders(r);
1075
+ o.store.add({
1076
+ id: newId,
1077
+ tunnel: r.tunnel,
1078
+ method: r.method,
1079
+ path: r.path,
1080
+ status: null,
1081
+ durationMs: null,
1082
+ bytes: 0,
1083
+ startedAt: started,
1084
+ websocket: false,
1085
+ reqHeaders: headers,
1086
+ resHeaders: {},
1087
+ reqBody: r.reqBody,
1088
+ resBody: null,
1089
+ reqRaw: r.reqRaw,
1090
+ reqBodyBytes: r.reqBodyBytes,
1091
+ reqTruncated: false,
1092
+ reqComplete: true,
1093
+ truncated: false,
1094
+ resTruncated: false,
1095
+ replayOf: r.id
1096
+ });
1097
+ void (async () => {
1098
+ const host = await localHost(route.port);
1099
+ const upstream = request({ host, port: route.port, agent: LOCAL_AGENT, method: r.method, path: r.path, headers }, (up) => trackResponse(newId, started, up));
1100
+ upstream.on("error", (err) => o.store.update(newId, { status: 503, durationMs: Date.now() - started, error: err.message || err.code || err.name }));
1101
+ upstream.end(r.reqRaw ?? void 0);
1102
+ })();
1103
+ return { ok: true, id: newId };
1104
+ }
1011
1105
  const server = createServer(async (req, res) => {
1012
1106
  req.pause();
1013
1107
  const route = await routeFor(req);
@@ -1031,12 +1125,12 @@ async function startProxy(o) {
1031
1125
  const headers = delivered(req.headers, route);
1032
1126
  if (access?.kind === "denied") {
1033
1127
  const html = wantsHtml(req.headers.accept);
1034
- const { id: id2, started: started2 } = record(req, route, headers, false, html ? void 0 : access);
1128
+ const { id: id2, started: started2 } = record(req, route, headers, false, html ? void 0 : access, DENIED_NO_REPLAY);
1035
1129
  const { status, out, body } = deny(req, res, html);
1036
1130
  o.store.update(id2, { status, durationMs: Date.now() - started2, bytes: Buffer.byteLength(body), resHeaders: out });
1037
1131
  return;
1038
1132
  }
1039
- const { id, started } = record(req, route, headers, false, access);
1133
+ const { id, started } = record(req, route, headers, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
1040
1134
  const reqCap = new Capture(TEXT.test(req.headers["content-type"] ?? ""), max);
1041
1135
  req.pause();
1042
1136
  const host = await localHost(route.port);
@@ -1051,35 +1145,19 @@ async function startProxy(o) {
1051
1145
  up.resume();
1052
1146
  return;
1053
1147
  }
1054
- const resCap = new Capture(TEXT.test(up.headers["content-type"] ?? ""), max);
1055
- let bytes = 0;
1056
1148
  if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
1057
1149
  up.headers.location = rewriteLocation(up.headers.location, route.host, hostOf(req));
1058
1150
  }
1059
1151
  res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
1060
- up.on("data", (c) => {
1061
- bytes += c.length;
1062
- resCap.push(c);
1063
- });
1152
+ trackResponse(id, started, up, reqCap);
1064
1153
  up.pipe(res);
1065
- up.on(
1066
- "end",
1067
- () => o.store.update(id, {
1068
- status: up.statusCode ?? null,
1069
- durationMs: Date.now() - started,
1070
- bytes,
1071
- resHeaders: flat(up.headers),
1072
- reqBody: reqCap.text(),
1073
- resBody: resCap.text(),
1074
- truncated: reqCap.truncated || resCap.truncated
1075
- })
1076
- );
1077
1154
  }
1078
1155
  );
1079
1156
  res.on("close", () => {
1080
1157
  if (!res.writableFinished) upstream.destroy();
1081
1158
  });
1082
1159
  upstream.on("error", (err) => {
1160
+ req.resume();
1083
1161
  if (res.destroyed && res.writableFinished === false && !res.headersSent) return;
1084
1162
  const html = wantsHtml(req.headers.accept);
1085
1163
  const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `Nada escutando em localhost:${route.port}. Suba o servidor e tente de novo.`;
@@ -1089,6 +1167,7 @@ async function startProxy(o) {
1089
1167
  } else res.destroy();
1090
1168
  o.store.update(id, { status: 503, durationMs: Date.now() - started, bytes: res.headersSent ? 0 : Buffer.byteLength(msg), error: err.message || err.code || err.name, reqBody: reqCap.text() });
1091
1169
  });
1170
+ req.on("end", () => o.store.update(id, { reqComplete: true, reqRaw: reqCap.truncated ? null : reqCap.raw(), reqBodyBytes: reqCap.total, reqTruncated: reqCap.truncated, reqBody: reqCap.text() }));
1092
1171
  if (preflight) {
1093
1172
  req.resume();
1094
1173
  upstream.end();
@@ -1103,7 +1182,7 @@ async function startProxy(o) {
1103
1182
  if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
1104
1183
  const access = route.auth?.access(req.headers);
1105
1184
  const headers = delivered(req.headers, route);
1106
- const { id, started } = record(req, route, headers, true, access);
1185
+ const { id, started } = record(req, route, headers, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
1107
1186
  if (access?.kind === "denied") {
1108
1187
  client.end(WS_DENIED);
1109
1188
  o.store.update(id, { status: 401, durationMs: Date.now() - started });
@@ -1146,6 +1225,7 @@ async function startProxy(o) {
1146
1225
  });
1147
1226
  return {
1148
1227
  port: server.address().port,
1228
+ replay,
1149
1229
  close: () => new Promise((resolve) => {
1150
1230
  for (const s of sockets) s.destroy();
1151
1231
  server.close(() => resolve());
@@ -1241,6 +1321,9 @@ var RecordStore = class {
1241
1321
  Object.assign(r, patch);
1242
1322
  this.emit();
1243
1323
  }
1324
+ get(id) {
1325
+ return this.items.find((x) => x.id === id);
1326
+ }
1244
1327
  /** Mais recentes primeiro. Mesma referência até a próxima mudança (serve de snapshot para a tela). */
1245
1328
  list() {
1246
1329
  this.snapshot ??= [...this.items].reverse();
@@ -1348,6 +1431,10 @@ var Session = class {
1348
1431
  this.auths.set(key, auth);
1349
1432
  return auth;
1350
1433
  }
1434
+ /** Reenvia uma requisição da lista ao serviço local. */
1435
+ replay(id) {
1436
+ return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "Sess\xE3o ainda n\xE3o come\xE7ou." };
1437
+ }
1351
1438
  async stop() {
1352
1439
  if (this.stopped) return;
1353
1440
  this.stopped = true;
@@ -1601,10 +1688,10 @@ async function runSession(specs, source, flags) {
1601
1688
  }
1602
1689
  const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source, runDir: join6(dutoHome(), "run") });
1603
1690
  if (wantsUi(flags)) {
1604
- const { runFullscreen } = await import("./fullscreen-DKIFHB6J.js");
1605
- process.exit(await runFullscreen(session, specs, "0.5.0"));
1691
+ const { runFullscreen } = await import("./fullscreen-7P5GBCQX.js");
1692
+ process.exit(await runFullscreen(session, specs, "0.6.0"));
1606
1693
  }
1607
- console.log(`${mark()} ${paint("muted", `${"0.5.0"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
1694
+ console.log(`${mark()} ${paint("muted", `${"0.6.0"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
1608
1695
  const stopLog = runPlain(session, (l) => console.log(l));
1609
1696
  const stopped = new Promise((resolve) => {
1610
1697
  const onSignal = () => {
@@ -1698,7 +1785,7 @@ cli.help(
1698
1785
  body: s.body.replace(/^\s*For more info, run any command with the `--help` flag:/m, " Mais detalhes de um comando: duto <comando> --help").replace("Display this message", "Mostra esta ajuda").replace("Display version number", "Mostra a vers\xE3o")
1699
1786
  }))
1700
1787
  );
1701
- cli.version("0.5.0");
1788
+ cli.version("0.6.0");
1702
1789
  try {
1703
1790
  cli.parse(process.argv, { run: false });
1704
1791
  await cli.runMatchedCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Exponha uma porta local em https://<nome>-<usuário>.duto.dev com um comando.",
5
5
  "license": "MIT",
6
6
  "type": "module",