wawesome 0.0.6 → 0.0.8

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 (3) hide show
  1. package/README.md +35 -0
  2. package/dist/index.mjs +1125 -335
  3. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -5,7 +5,9 @@ import { build } from "esbuild";
5
5
  import os from "node:os";
6
6
  import http from "node:http";
7
7
  import readline from "node:readline";
8
- import { select } from "@inquirer/prompts";
8
+ import { confirm, select } from "@inquirer/prompts";
9
+ import { spawnSync } from "node:child_process";
10
+ import zlib from "node:zlib";
9
11
  //#region src/config.ts
10
12
  /**
11
13
  * Hardcoded Supabase project config for the wawesome.io platform.
@@ -124,6 +126,7 @@ async function buildJs(entryInput, options) {
124
126
  try {
125
127
  await build({
126
128
  entryPoints: [entry],
129
+ absWorkingDir: process.cwd(),
127
130
  bundle: true,
128
131
  format: "esm",
129
132
  outfile: outPath,
@@ -148,6 +151,18 @@ async function buildJs(entryInput, options) {
148
151
  }
149
152
  }
150
153
  //#endregion
154
+ //#region src/cli-version.ts
155
+ /**
156
+ * The version of the CLI that is running, read from the package it ships in.
157
+ *
158
+ * Compiled in at bundle time rather than resolved from disk, because `dist/` is
159
+ * what gets published and the manifest next to it is not where a `npx` cache,
160
+ * a global install, and a workspace checkout all agree it will be. Anything
161
+ * that has to name this version — `--version`, the dependency a scaffolded
162
+ * project pins — reads it here, so a release bumps one file.
163
+ */
164
+ const CLI_VERSION = "0.0.8";
165
+ //#endregion
151
166
  //#region src/prompt.ts
152
167
  /**
153
168
  * Open a prompt session on stdin, queueing lines as they arrive.
@@ -203,27 +218,44 @@ function isInteractive() {
203
218
  return Boolean(process.stdin.isTTY);
204
219
  }
205
220
  //#endregion
206
- //#region src/tenant.ts
221
+ //#region src/errors.ts
207
222
  /** A rejection carrying the gateway's own prose and, where given, its reason. */
208
223
  var GatewayError = class extends Error {
209
224
  status;
210
225
  reason;
211
- constructor(message, status, reason) {
226
+ /** The raw response, for `--verbose` to print when the prose is not enough. */
227
+ body;
228
+ constructor(message, status, reason, body = "") {
212
229
  super(message);
213
230
  this.name = "GatewayError";
214
231
  this.status = status;
215
232
  this.reason = reason;
233
+ this.body = body;
216
234
  }
217
235
  };
236
+ /**
237
+ * Read a failed response into a `GatewayError`.
238
+ *
239
+ * `fallback` is used only when the response carries no prose of its own — a
240
+ * proxy's HTML error page, or an empty body. A 401 is deliberately *not* special
241
+ * cased here: what to say about an expired session depends on what the caller
242
+ * was doing, so callers handle it before reaching this.
243
+ */
218
244
  async function asGatewayError(res, fallback) {
219
- const body = await res.text();
245
+ const body = await res.text().catch(() => "");
220
246
  try {
221
247
  const parsed = JSON.parse(body);
222
- return new GatewayError(parsed.error || fallback, res.status, parsed.reason);
248
+ return new GatewayError(parsed.error || fallback, res.status, parsed.reason, body);
223
249
  } catch {
224
- return new GatewayError(fallback, res.status);
250
+ return new GatewayError(fallback, res.status, void 0, body);
225
251
  }
226
252
  }
253
+ /** What went wrong, as text, whatever was thrown. */
254
+ function errorText(err) {
255
+ return err instanceof Error ? err.message : String(err);
256
+ }
257
+ //#endregion
258
+ //#region src/tenant.ts
227
259
  async function fetchTenantDetails(creds) {
228
260
  const res = await fetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
229
261
  if (!res.ok) throw await asGatewayError(res, `Failed to read workspace (HTTP ${res.status}).`);
@@ -600,195 +632,1123 @@ async function deploy(entryInput, options) {
600
632
  if (version !== void 0) console.log(` Version: ${version}`);
601
633
  if (invokeUrl) console.log(`\n URL: \x1b[36m${invokeUrl}\x1b[0m`);
602
634
  console.log("======================================================\n");
635
+ return {
636
+ app,
637
+ functionName: funcName,
638
+ version,
639
+ invokeUrl
640
+ };
603
641
  }
642
+ //#endregion
643
+ //#region src/env.ts
644
+ const STANDARD_SECRET_MESSAGES = [
645
+ "Encrypted at rest using AES-256",
646
+ "This value can't be viewed again after you save it — only updated or deleted",
647
+ "Only decrypted at the moment your function runs — never returned by the CLI, dashboard, or API after creation."
648
+ ];
604
649
  /**
605
- * Normalise arbitrary input into a legal DNS label, returning an empty string
606
- * when nothing legal survives — callers decide whether that is an error.
607
- */
608
- function sanitizeSlug(raw) {
609
- let label = "";
610
- for (const c of raw.trim().toLowerCase()) if (c >= "a" && c <= "z") label += c;
611
- else if (c >= "0" && c <= "9") label += c;
612
- else if (label.length > 0 && !label.endsWith("-")) label += "-";
613
- label = label.slice(0, 63);
614
- while (label.endsWith("-")) label = label.slice(0, -1);
615
- return label;
616
- }
617
- /**
618
- * Whether `candidate` is already a legal DNS label — i.e. whether
619
- * {@link sanitizeSlug} would leave it untouched.
650
+ * Load credentials and validate project config.
620
651
  */
621
- function isLegalSlug(candidate) {
622
- return candidate.length > 0 && candidate.length <= 63 && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
652
+ function loadClientContext() {
653
+ const creds = readCredentials();
654
+ if (!creds) {
655
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
656
+ process.exit(1);
657
+ }
658
+ const config = readFunctionConfig();
659
+ if (!config || !config.app || !config.app.trim()) {
660
+ console.error("[wawesome] Error: No wawesome-function.json found in the current directory.");
661
+ console.error("[wawesome] Run 'wawesome init' to create one, or create it manually.");
662
+ process.exit(1);
663
+ }
664
+ return {
665
+ creds,
666
+ app: config.app.trim()
667
+ };
623
668
  }
624
- //#endregion
625
- //#region src/init.ts
626
- /** Bound on the re-prompt loop, so unusable input exits instead of looping. */
627
- const MAX_APP_SLUG_ATTEMPTS = 5;
628
669
  /**
629
- * Ask which App this Function belongs to, defaulting to the directory name.
670
+ * Store one variable on an App, reporting failure by throwing.
630
671
  *
631
- * Never falls back to `default-app`, and re-offers an illegal answer as a
632
- * suggestion rather than rewriting it: the slug is part of the public URL a
633
- * client reads, so the user sees the name they will get before agreeing to it.
634
- */
635
- async function promptForAppSlug(session, dirName) {
636
- let suggestion = sanitizeSlug(dirName);
637
- for (let attempt = 0; attempt < MAX_APP_SLUG_ATTEMPTS; attempt++) {
638
- const answer = await session.ask(" App slug?", suggestion);
639
- if (isLegalSlug(answer)) return answer;
640
- const sanitized = sanitizeSlug(answer);
641
- if (sanitized) {
642
- console.log(`[wawesome] '${answer}' can't be used as an App slug. Suggested: '${sanitized}'.`);
643
- suggestion = sanitized;
644
- } else console.log("[wawesome] An App slug needs lowercase letters or numbers, separated by single hyphens.");
672
+ * The endpoint half of `env set`, without the exiting: template scaffolding
673
+ * stores a whole manifest's worth of variables and has already written files by
674
+ * the time it does, so it needs to say what did land before it gives up.
675
+ */
676
+ async function putEnvVar(creds, app, key, value, isSecret) {
677
+ const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
678
+ const res = await fetch(url, {
679
+ method: "POST",
680
+ headers: {
681
+ Authorization: `Bearer ${creds.tenant_jwt}`,
682
+ "Content-Type": "application/json"
683
+ },
684
+ body: JSON.stringify({
685
+ key,
686
+ value,
687
+ is_secret: isSecret
688
+ })
689
+ });
690
+ if (!res.ok) {
691
+ if (res.status === 401) throw new GatewayError("Authentication expired. Run 'wawesome login' again.", 401);
692
+ throw await asGatewayError(res, `Failed to set variable '${key}' (HTTP ${res.status}).`);
645
693
  }
646
- console.error(`[wawesome] Error: no usable App slug after ${MAX_APP_SLUG_ATTEMPTS} attempts. Nothing was written.`);
647
- console.error("[wawesome] Run 'wawesome init' again once you know what to call the App.");
648
- process.exit(1);
694
+ return await res.json();
649
695
  }
650
696
  /**
651
- * Scaffold a new wawesome function project in the current directory.
697
+ * Set or overwrite an environment variable on the current app.
652
698
  */
653
- async function init(options) {
699
+ async function setEnvVar(key, value, options) {
654
700
  const isVerbose = Boolean(options.verbose);
655
- const cwd = process.cwd();
656
- const dirName = path.basename(cwd);
657
- console.log("[wawesome] Initializing a new function project...\n");
658
- const session = openPromptSession();
659
- let functionName;
660
- let appSlug;
661
- try {
662
- functionName = await session.ask(" Function name?", dirName);
663
- console.log(" (an App groups the Functions of one project — its slug is part of the public URL)");
664
- appSlug = await promptForAppSlug(session, dirName);
665
- } finally {
666
- session.close();
667
- }
668
- const entry = "src/index.ts";
669
- if (isVerbose) console.log(`[wawesome:verbose] function=${functionName}, app=${appSlug}, entry=${entry}`);
670
- const configContent = {
671
- app: appSlug,
672
- function: functionName,
673
- entry
674
- };
675
- const configPath = path.join(cwd, "wawesome-function.json");
676
- if (fs.existsSync(configPath)) console.log("[wawesome] wawesome-function.json already exists, skipping.");
677
- else {
678
- fs.writeFileSync(configPath, JSON.stringify(configContent, null, 2) + "\n", "utf-8");
679
- console.log("[wawesome] ✅ Created wawesome-function.json");
680
- }
681
- const srcDir = path.join(cwd, "src");
682
- const indexPath = path.join(srcDir, "index.ts");
683
- if (fs.existsSync(indexPath)) console.log("[wawesome] src/index.ts already exists, skipping.");
684
- else {
685
- fs.mkdirSync(srcDir, { recursive: true });
686
- fs.writeFileSync(indexPath, `/**
687
- * Serverless Function Handler
688
- * Standard HTTP fetch request handler
689
- */
690
- export default {
691
- async fetch(request: Request): Promise<Response> {
692
- return Response.json({
693
- message: "Hello, World!",
694
- });
695
- },
696
- };
697
- `, "utf-8");
698
- console.log("[wawesome] ✅ Created src/index.ts");
699
- }
700
- const pkgPath = path.join(cwd, "package.json");
701
- if (fs.existsSync(pkgPath)) console.log("[wawesome] package.json already exists, skipping.");
702
- else {
703
- const pkgContent = {
704
- name: functionName,
705
- type: "module",
706
- version: "1.0.0",
707
- description: `Serverless function: ${functionName}`,
708
- scripts: {
709
- build: "wawesome build",
710
- deploy: "wawesome deploy",
711
- typecheck: "tsc --noEmit"
712
- },
713
- devDependencies: {
714
- "wawesome": "^1.0.0",
715
- typescript: "^7.0.0"
716
- }
717
- };
718
- fs.writeFileSync(pkgPath, JSON.stringify(pkgContent, null, 2) + "\n", "utf-8");
719
- console.log("[wawesome] ✅ Created package.json");
701
+ if (!key || value === void 0) {
702
+ console.error("[wawesome] Error: Missing key or value. Usage: wawesome env set <KEY> <VALUE> [--secret]");
703
+ process.exit(1);
720
704
  }
721
- const tsconfigPath = path.join(cwd, "tsconfig.json");
722
- if (fs.existsSync(tsconfigPath)) console.log("[wawesome] tsconfig.json already exists, skipping.");
723
- else {
724
- fs.writeFileSync(tsconfigPath, JSON.stringify({
725
- compilerOptions: {
726
- target: "ES2022",
727
- module: "ESNext",
728
- moduleResolution: "bundler",
729
- strict: true,
730
- noEmit: true,
731
- lib: [
732
- "ES2022",
733
- "DOM",
734
- "DOM.Iterable"
735
- ]
736
- },
737
- include: ["src"]
738
- }, null, 2) + "\n", "utf-8");
739
- console.log("[wawesome] ✅ Created tsconfig.json");
705
+ const { creds, app } = loadClientContext();
706
+ const isSecret = Boolean(options.secret);
707
+ if (isVerbose) {
708
+ console.log(`[wawesome:verbose] POST ${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`);
709
+ console.log(`[wawesome:verbose] Payload: key=${key}, is_secret=${isSecret}`);
740
710
  }
741
- const gitignorePath = path.join(cwd, ".gitignore");
742
- if (fs.existsSync(gitignorePath)) console.log("[wawesome] .gitignore already exists, skipping.");
743
- else {
744
- fs.writeFileSync(gitignorePath, "node_modules\ndist\n", "utf-8");
745
- console.log("[wawesome] ✅ Created .gitignore");
711
+ let data;
712
+ try {
713
+ data = await putEnvVar(creds, app, key, value, isSecret);
714
+ } catch (err) {
715
+ console.error(`[wawesome] Error: ${errorText(err)}`);
716
+ if (isVerbose && err instanceof GatewayError) console.error(`[wawesome:verbose] Response: ${err.body}`);
717
+ process.exit(1);
718
+ return;
746
719
  }
747
- console.log("\n[wawesome] 🎉 Project initialized! Next steps:");
748
- console.log(" 1. npm install");
749
- console.log(" 2. wawesome login");
750
- console.log(" 3. wawesome deploy\n");
720
+ if (data.variable.is_secret || isSecret) {
721
+ console.log(`\n[wawesome] ✅ Set secret variable '${key}' on app '${app}'.`);
722
+ const messages = data.notice?.messages?.length ? data.notice.messages : STANDARD_SECRET_MESSAGES;
723
+ console.log("\n🔒 \x1B[1mSecret Notice:\x1B[0m");
724
+ for (const msg of messages) console.log(` • ${msg}`);
725
+ console.log("");
726
+ } else console.log(`[wawesome] ✅ Set variable '${key}' on app '${app}'.`);
751
727
  }
752
- //#endregion
753
- //#region src/version.ts
754
- async function fetchVersions(gatewayUrl, tenantJwt, app, funcName, env, isVerbose) {
755
- const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/versions?environment=${encodeURIComponent(env)}`;
728
+ function truncate(str, maxLen) {
729
+ if (str.length <= maxLen) return str;
730
+ if (maxLen <= 3) return str.slice(0, maxLen);
731
+ return str.slice(0, maxLen - 1) + "…";
732
+ }
733
+ /**
734
+ * List environment variables on the current app.
735
+ */
736
+ async function listEnvVars(options) {
737
+ const isVerbose = Boolean(options.verbose);
738
+ const { creds, app } = loadClientContext();
739
+ const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
756
740
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
757
741
  const res = await fetch(url, {
758
742
  method: "GET",
759
- headers: { Authorization: `Bearer ${tenantJwt}` }
743
+ headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
760
744
  });
761
745
  if (!res.ok) {
762
746
  const errorBody = await res.text();
763
747
  if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
764
748
  else {
765
- console.error(`[wawesome] Error: Failed to list versions (HTTP ${res.status}).`);
749
+ console.error(`[wawesome] Error: Failed to list variables (HTTP ${res.status}).`);
766
750
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
767
751
  }
768
752
  process.exit(1);
769
753
  }
770
- return await res.json();
754
+ const data = await res.json();
755
+ if (!data.variables || data.variables.length === 0) {
756
+ console.log(`[wawesome] No environment variables set for app '${app}'.`);
757
+ return;
758
+ }
759
+ const availableWidth = (process.stdout.columns && process.stdout.columns > 40 ? process.stdout.columns : 80) - 12;
760
+ let rawMaxKeyLen = 15;
761
+ let rawMaxValLen = 15;
762
+ for (const item of data.variables) {
763
+ if (item.key.length > rawMaxKeyLen) rawMaxKeyLen = item.key.length;
764
+ if (item.value.length > rawMaxValLen) rawMaxValLen = item.value.length;
765
+ }
766
+ const maxKeyLen = Math.min(rawMaxKeyLen, Math.max(15, Math.floor(availableWidth * .4)));
767
+ const maxValLen = Math.min(rawMaxValLen, Math.max(15, availableWidth - maxKeyLen));
768
+ console.log(`\n📋 \x1b[1mEnvironment variables for '${app}'\x1b[0m\n`);
769
+ const headerKey = "KEY".padEnd(maxKeyLen);
770
+ const headerVal = "VALUE".padEnd(maxValLen);
771
+ console.log(`${headerKey} | ${headerVal} | SECRET`);
772
+ console.log(`${"-".repeat(maxKeyLen)}-|- ${"-".repeat(maxValLen)} |-------`);
773
+ for (const item of data.variables) {
774
+ const truncatedKey = truncate(item.key, maxKeyLen);
775
+ const truncatedVal = truncate(item.value, maxValLen);
776
+ const keyStr = truncatedKey.padEnd(maxKeyLen);
777
+ const valStr = truncatedVal.padEnd(maxValLen);
778
+ const secretStr = item.is_secret ? "\x1B[33mtrue\x1B[0m" : "false";
779
+ console.log(`${keyStr} | ${valStr} | ${secretStr}`);
780
+ }
781
+ console.log("");
771
782
  }
772
783
  /**
773
- * List existing function versions.
784
+ * Delete an environment variable on the current app.
774
785
  */
775
- async function listVersions(options) {
786
+ async function removeEnvVar(key, options) {
776
787
  const isVerbose = Boolean(options.verbose);
777
- const env = options.env || "production";
778
- const creds = readCredentials();
779
- if (!creds) {
780
- console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
781
- process.exit(1);
782
- }
783
- const config = readFunctionConfig();
784
- if (!config) {
785
- console.error("[wawesome] Error: No wawesome-function.json found in the current directory.");
788
+ if (!key) {
789
+ console.error("[wawesome] Error: Missing variable key. Usage: wawesome env rm <KEY>");
786
790
  process.exit(1);
787
791
  }
788
- const { app, function: funcName } = config;
789
- const data = await fetchVersions(creds.gateway_url, creds.tenant_jwt, app, funcName, env, isVerbose);
790
- if (!data.versions || data.versions.length === 0) {
791
- console.log(`[wawesome] No versions found for '${app}/${funcName}'. Deploy code first using 'wawesome deploy'.`);
792
+ const { creds, app } = loadClientContext();
793
+ const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env/${encodeURIComponent(key)}`;
794
+ if (isVerbose) console.log(`[wawesome:verbose] DELETE ${url}`);
795
+ const res = await fetch(url, {
796
+ method: "DELETE",
797
+ headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
798
+ });
799
+ if (!res.ok) {
800
+ const errorBody = await res.text();
801
+ if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
802
+ else if (res.status === 404) console.error(`[wawesome] Error: Variable '${key}' not found on app '${app}'.`);
803
+ else {
804
+ console.error(`[wawesome] Error: Failed to delete variable '${key}' (HTTP ${res.status}).`);
805
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
806
+ }
807
+ process.exit(1);
808
+ }
809
+ console.log(`[wawesome] ✅ Deleted variable '${key}' from app '${app}'.`);
810
+ }
811
+ /**
812
+ * Dispatcher function for `wawesome env` CLI command.
813
+ */
814
+ async function envCommand(action, key, value, options) {
815
+ if (!action || action === "list" || action === "ls") return listEnvVars(options);
816
+ if (action === "set") return setEnvVar(key, value, options);
817
+ if (action === "rm" || action === "remove" || action === "delete" || action === "unset") return removeEnvVar(key, options);
818
+ console.error(`[wawesome] Error: Unknown env action '${action}'. Available actions: list, set, rm.`);
819
+ process.exit(1);
820
+ }
821
+ //#endregion
822
+ //#region src/install.ts
823
+ /** Package managers whose `install` this understands. */
824
+ const KNOWN_MANAGERS = [
825
+ "npm",
826
+ "pnpm",
827
+ "yarn",
828
+ "bun"
829
+ ];
830
+ /**
831
+ * Which package manager to install with.
832
+ *
833
+ * Taken from the one that launched this process — `pnpm dlx wawesome init`
834
+ * should not leave a `package-lock.json` behind — and npm otherwise, since that
835
+ * is what `npx` is.
836
+ */
837
+ function packageManager(userAgent = process.env.npm_config_user_agent) {
838
+ const name = (userAgent ?? "").split("/")[0];
839
+ return KNOWN_MANAGERS.includes(name) ? name : "npm";
840
+ }
841
+ function installDependencies(dir, options = {}) {
842
+ const manager = packageManager();
843
+ const command = `${manager} install`;
844
+ if (!fs.existsSync(path.join(dir, "package.json"))) return {
845
+ command,
846
+ ok: false
847
+ };
848
+ console.log(`\n[wawesome] Installing dependencies (${command})...`);
849
+ const result = spawnSync(manager, ["install"], {
850
+ cwd: dir,
851
+ stdio: options.verbose ? "inherit" : [
852
+ "ignore",
853
+ "ignore",
854
+ "pipe"
855
+ ],
856
+ shell: process.platform === "win32"
857
+ });
858
+ if (result.status === 0) {
859
+ console.log("[wawesome] ✅ Dependencies installed.");
860
+ return {
861
+ command,
862
+ ok: true
863
+ };
864
+ }
865
+ const stderr = result.stderr?.toString().trim();
866
+ console.log(`[wawesome] ${command} did not finish. Run it yourself when you can.`);
867
+ if (stderr) console.log(`[wawesome] ${stderr.split("\n").slice(-3).join("\n[wawesome] ")}`);
868
+ return {
869
+ command,
870
+ ok: false
871
+ };
872
+ }
873
+ //#endregion
874
+ //#region src/provision.ts
875
+ /** The gateway's own words for a failure, or a plain HTTP status if it gave none. */
876
+ async function rejected(res, fallback) {
877
+ if (res.status === 401) return new GatewayError("Authentication expired. Run 'wawesome login' again.", 401);
878
+ return asGatewayError(res, `${fallback} (HTTP ${res.status}).`);
879
+ }
880
+ function authHeaders(creds) {
881
+ return {
882
+ Authorization: `Bearer ${creds.tenant_jwt}`,
883
+ "Content-Type": "application/json"
884
+ };
885
+ }
886
+ /**
887
+ * Make sure the App exists before anything is attached to it.
888
+ *
889
+ * Environment variables and subscriptions are both App-scoped, and neither
890
+ * endpoint creates one — deploy would, but it runs last, and a Function that
891
+ * goes live before its secrets do is a Function that fails its first request.
892
+ *
893
+ * Listing first rather than creating and reading the error keeps the common
894
+ * case ("I already have this App") off the failure path; an App that appears in
895
+ * between is still treated as the success it is.
896
+ */
897
+ async function ensureApp(creds, appSlug) {
898
+ const listRes = await fetch(`${creds.gateway_url}/v1/apps`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
899
+ if (!listRes.ok) throw await rejected(listRes, "Could not list the Tenant's apps");
900
+ if ((await listRes.json()).applications?.some((app) => app.slug === appSlug)) return;
901
+ const createRes = await fetch(`${creds.gateway_url}/v1/apps`, {
902
+ method: "POST",
903
+ headers: authHeaders(creds),
904
+ body: JSON.stringify({
905
+ name: appSlug,
906
+ slug: appSlug
907
+ })
908
+ });
909
+ if (createRes.ok) return;
910
+ const error = await rejected(createRes, `Could not create the app '${appSlug}'`);
911
+ if (/already exists/i.test(error.message)) return;
912
+ throw error;
913
+ }
914
+ /**
915
+ * Enable one catalogue provider for the App.
916
+ *
917
+ * A template declares the providers it calls; without the subscription the
918
+ * platform's egress policy denies those calls, so this is what stands between a
919
+ * scaffolded template and an outbound request that silently fails at runtime.
920
+ */
921
+ async function enableEgressProvider(creds, appSlug, providerKey) {
922
+ const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(appSlug)}/egress/subscriptions`;
923
+ const res = await fetch(url, {
924
+ method: "POST",
925
+ headers: authHeaders(creds),
926
+ body: JSON.stringify({ provider_key: providerKey })
927
+ });
928
+ if (!res.ok) throw await rejected(res, `Could not enable outbound provider '${providerKey}'`);
929
+ }
930
+ //#endregion
931
+ //#region src/scaffold.ts
932
+ const MANIFEST_FILE = "template.json";
933
+ const PACKAGE_FILE = "package.json";
934
+ const FUNCTION_CONFIG_FILE = "wawesome-function.json";
935
+ /** Read the template's requirements declaration out of a scaffolded directory. */
936
+ function readTemplateManifest(dir) {
937
+ const manifestPath = path.join(dir, MANIFEST_FILE);
938
+ if (!fs.existsSync(manifestPath)) throw new Error(`The template has no ${MANIFEST_FILE}, so there is nothing to set it up from.`);
939
+ let raw;
940
+ try {
941
+ raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
942
+ } catch (err) {
943
+ throw new Error(`The template's ${MANIFEST_FILE} is not valid JSON: ${errorText(err)}`);
944
+ }
945
+ return {
946
+ env: Array.isArray(raw.env) ? raw.env : [],
947
+ providers: Array.isArray(raw.providers) ? raw.providers : [],
948
+ post_deploy: typeof raw.post_deploy === "string" ? raw.post_deploy : ""
949
+ };
950
+ }
951
+ /**
952
+ * Patch the two configuration files, and nothing else.
953
+ *
954
+ * Every placeholder originally scoped for substitution turned out to be
955
+ * something else: the Tenant slug never appears in template source, and a
956
+ * template's environment-variable *names* are fixed by its own code. What is
957
+ * left is identity — which App this is, and what the Function is called.
958
+ */
959
+ function applyTemplateIdentity(dir, identity) {
960
+ patchJson(path.join(dir, PACKAGE_FILE), (pkg) => ({
961
+ ...pkg,
962
+ name: identity.functionName
963
+ }));
964
+ patchJson(path.join(dir, FUNCTION_CONFIG_FILE), (config) => ({
965
+ ...config,
966
+ app: identity.app,
967
+ function: identity.functionName
968
+ }));
969
+ }
970
+ /** The package the CLI publishes itself as, and which a template depends on. */
971
+ const CLI_PACKAGE = "wawesome";
972
+ /**
973
+ * Point the template's dependency on the CLI at the CLI that is scaffolding it.
974
+ *
975
+ * A template pins the version it was released against, and below 1.0 a caret
976
+ * range is an exact pin — `^0.0.6` never resolves to `0.0.7`. So a template
977
+ * whose repository has not been touched since installs an older CLI than the
978
+ * one the user just ran, and ships types that predate the platform it is about
979
+ * to deploy to. Rewriting it here keeps the two in step without a template
980
+ * release for every CLI release.
981
+ *
982
+ * Only a declaration that already exists is rewritten: what a template depends
983
+ * on is the template's business, and a template that does not use the CLI does
984
+ * not acquire it here.
985
+ */
986
+ function alignCliDependency(dir, version) {
987
+ const file = path.join(dir, PACKAGE_FILE);
988
+ const pkg = readJson(file);
989
+ const wanted = `^${version}`;
990
+ for (const field of ["dependencies", "devDependencies"]) {
991
+ const deps = pkg[field];
992
+ if (!isRecord(deps)) continue;
993
+ const current = deps[CLI_PACKAGE];
994
+ if (typeof current !== "string" || current === wanted) continue;
995
+ writeJson(file, {
996
+ ...pkg,
997
+ [field]: {
998
+ ...deps,
999
+ [CLI_PACKAGE]: wanted
1000
+ }
1001
+ });
1002
+ return {
1003
+ from: current,
1004
+ to: wanted
1005
+ };
1006
+ }
1007
+ return null;
1008
+ }
1009
+ function isRecord(value) {
1010
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1011
+ }
1012
+ function patchJson(file, patch) {
1013
+ writeJson(file, patch(readJson(file)));
1014
+ }
1015
+ function readJson(file) {
1016
+ if (!fs.existsSync(file)) throw new Error(`The template is missing ${path.basename(file)}, which scaffolding must patch.`);
1017
+ try {
1018
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
1019
+ } catch (err) {
1020
+ throw new Error(`The template's ${path.basename(file)} is not valid JSON: ${errorText(err)}`);
1021
+ }
1022
+ }
1023
+ function writeJson(file, content) {
1024
+ fs.writeFileSync(file, JSON.stringify(content, null, 2) + "\n", "utf-8");
1025
+ }
1026
+ /**
1027
+ * Which of the files a scaffold is about to write are already there.
1028
+ *
1029
+ * Checked before the first byte is written: half a template over half a project
1030
+ * is worse than refusing, and the user is the only one who knows which they
1031
+ * meant to keep.
1032
+ */
1033
+ function collidingPaths(dir, relativePaths) {
1034
+ return relativePaths.filter((relative) => fs.existsSync(path.join(dir, relative)));
1035
+ }
1036
+ /**
1037
+ * Fill the post-deploy message in. `{{url}}` is the only placeholder anywhere in
1038
+ * the template system, and it never touches a source file — if the address could
1039
+ * not be resolved the message still prints, with the placeholder left visible
1040
+ * rather than a confident "undefined".
1041
+ */
1042
+ function renderPostDeploy(postDeploy, invokeUrl) {
1043
+ if (!invokeUrl) return postDeploy;
1044
+ return postDeploy.split("{{url}}").join(invokeUrl);
1045
+ }
1046
+ /**
1047
+ * Normalise arbitrary input into a legal DNS label, returning an empty string
1048
+ * when nothing legal survives — callers decide whether that is an error.
1049
+ */
1050
+ function sanitizeSlug(raw) {
1051
+ let label = "";
1052
+ for (const c of raw.trim().toLowerCase()) if (c >= "a" && c <= "z") label += c;
1053
+ else if (c >= "0" && c <= "9") label += c;
1054
+ else if (label.length > 0 && !label.endsWith("-")) label += "-";
1055
+ label = label.slice(0, 63);
1056
+ while (label.endsWith("-")) label = label.slice(0, -1);
1057
+ return label;
1058
+ }
1059
+ /**
1060
+ * Whether `candidate` is already a legal DNS label — i.e. whether
1061
+ * {@link sanitizeSlug} would leave it untouched.
1062
+ */
1063
+ function isLegalSlug(candidate) {
1064
+ return candidate.length > 0 && candidate.length <= 63 && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
1065
+ }
1066
+ //#endregion
1067
+ //#region src/tarball.ts
1068
+ /**
1069
+ * Reading a repository archive, so scaffolding needs no git.
1070
+ *
1071
+ * Git being absent is the common case for the audience this platform is for, and
1072
+ * on Windows it is the *normal* case — so the template arrives as a `.tar.gz`
1073
+ * over plain HTTP and is unpacked here. Node ships gzip; the tar half is 512-byte
1074
+ * blocks, which is little enough code that carrying a dependency for it would
1075
+ * cost more than it saves.
1076
+ *
1077
+ * An archive is untrusted input even when it comes from a URL the platform
1078
+ * chose: extraction refuses anything whose path could land outside the
1079
+ * destination, and refuses links entirely, before writing a single file.
1080
+ */
1081
+ const BLOCK_SIZE = 512;
1082
+ /** Where each field a header carries begins, and how long it is. */
1083
+ const NAME = {
1084
+ at: 0,
1085
+ len: 100
1086
+ };
1087
+ const MODE = {
1088
+ at: 100,
1089
+ len: 8
1090
+ };
1091
+ const SIZE = {
1092
+ at: 124,
1093
+ len: 12
1094
+ };
1095
+ const TYPE_FLAG = 156;
1096
+ const MAGIC = {
1097
+ at: 257,
1098
+ len: 6
1099
+ };
1100
+ const PREFIX = {
1101
+ at: 345,
1102
+ len: 155
1103
+ };
1104
+ /** Entry types extraction acts on. Everything else is metadata or refused. */
1105
+ const TYPE_FILE = "0";
1106
+ const TYPE_FILE_ALT = "\0";
1107
+ const TYPE_DIRECTORY = "5";
1108
+ const TYPE_SYMLINK = "2";
1109
+ const TYPE_HARDLINK = "1";
1110
+ const TYPE_PAX_NEXT = "x";
1111
+ const TYPE_PAX_GLOBAL = "g";
1112
+ const TYPE_GNU_LONGNAME = "L";
1113
+ /** A tar the CLI cannot make sense of, phrased for whoever is watching. */
1114
+ var ArchiveError = class extends Error {};
1115
+ function field(block, at, len) {
1116
+ const raw = block.toString("utf-8", at, at + len);
1117
+ const end = raw.indexOf("\0");
1118
+ return end === -1 ? raw : raw.slice(0, end);
1119
+ }
1120
+ function octal(block, at, len) {
1121
+ const text = field(block, at, len).trim();
1122
+ if (!text) return 0;
1123
+ const value = parseInt(text, 8);
1124
+ return Number.isFinite(value) ? value : 0;
1125
+ }
1126
+ /**
1127
+ * The path an entry claims, honouring the two ways a long one is recorded:
1128
+ * split across the `ustar` prefix field, or carried by the pax header that
1129
+ * precedes it. Both come out of a real `git archive` — which one you get depends
1130
+ * only on where the path's slashes happen to fall.
1131
+ */
1132
+ function entryPath(block, paxPath) {
1133
+ if (paxPath) return paxPath;
1134
+ const name = field(block, NAME.at, NAME.len);
1135
+ const prefix = field(block, PREFIX.at, PREFIX.len);
1136
+ return prefix ? `${prefix}/${name}` : name;
1137
+ }
1138
+ /** The `path=` record of a pax extended header, if it declares one. */
1139
+ function paxHeaderPath(body) {
1140
+ let offset = 0;
1141
+ while (offset < body.length) {
1142
+ const space = body.indexOf(32, offset);
1143
+ if (space === -1) break;
1144
+ const length = parseInt(body.toString("ascii", offset, space), 10);
1145
+ if (!Number.isFinite(length) || length <= 0) break;
1146
+ const record = body.toString("utf-8", space + 1, offset + length).replace(/\n$/, "");
1147
+ const eq = record.indexOf("=");
1148
+ if (eq !== -1 && record.slice(0, eq) === "path") return record.slice(eq + 1);
1149
+ offset += length;
1150
+ }
1151
+ return null;
1152
+ }
1153
+ /**
1154
+ * Every file in a tar, in archive order.
1155
+ *
1156
+ * Refuses links rather than skipping them: a symlink extracted into the
1157
+ * destination turns a later, innocent-looking entry into a write anywhere on
1158
+ * the filesystem, and no template has a reason to ship one.
1159
+ */
1160
+ function readEntries(tar) {
1161
+ const entries = [];
1162
+ let paxPath = null;
1163
+ let offset = 0;
1164
+ let sawHeader = false;
1165
+ while (offset + BLOCK_SIZE <= tar.length) {
1166
+ const block = tar.subarray(offset, offset + BLOCK_SIZE);
1167
+ offset += BLOCK_SIZE;
1168
+ if (block.every((byte) => byte === 0)) break;
1169
+ if (!field(block, MAGIC.at, MAGIC.len).startsWith("ustar")) throw new ArchiveError("The downloaded file is not a tar archive.");
1170
+ sawHeader = true;
1171
+ const size = octal(block, SIZE.at, SIZE.len);
1172
+ const bodyEnd = offset + size;
1173
+ if (bodyEnd > tar.length) throw new ArchiveError("The downloaded archive is truncated.");
1174
+ const body = tar.subarray(offset, bodyEnd);
1175
+ offset += Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE;
1176
+ const type = String.fromCharCode(block[TYPE_FLAG]);
1177
+ if (type === TYPE_PAX_NEXT || type === TYPE_GNU_LONGNAME) {
1178
+ paxPath = type === TYPE_GNU_LONGNAME ? field(body, 0, body.length) : paxHeaderPath(body);
1179
+ continue;
1180
+ }
1181
+ if (type === TYPE_PAX_GLOBAL) continue;
1182
+ const recorded = entryPath(block, paxPath);
1183
+ paxPath = null;
1184
+ if (type === TYPE_SYMLINK || type === TYPE_HARDLINK) throw new ArchiveError(`The archive contains a link ('${recorded}'), which is not extracted.`);
1185
+ if (type === TYPE_DIRECTORY) continue;
1186
+ if (type !== TYPE_FILE && type !== TYPE_FILE_ALT) continue;
1187
+ entries.push({
1188
+ path: recorded,
1189
+ mode: octal(block, MODE.at, MODE.len),
1190
+ body
1191
+ });
1192
+ }
1193
+ if (!sawHeader) throw new ArchiveError("The downloaded file is not a tar archive.");
1194
+ return entries;
1195
+ }
1196
+ /** Drop the archive's single root directory, the way `--strip-components=1` does. */
1197
+ function withoutArchiveRoot(entryPath) {
1198
+ const firstSlash = entryPath.indexOf("/");
1199
+ if (firstSlash === -1) return null;
1200
+ return entryPath.slice(firstSlash + 1);
1201
+ }
1202
+ /**
1203
+ * The path an entry takes inside the template directory, or `null` if it belongs
1204
+ * to a different one. `.` means the template owns the whole repository.
1205
+ */
1206
+ function withinDirectory(entryPath, directory) {
1207
+ if (directory === "." || directory === "") return entryPath;
1208
+ const prefix = `${directory}/`;
1209
+ return entryPath.startsWith(prefix) ? entryPath.slice(prefix.length) : null;
1210
+ }
1211
+ /**
1212
+ * Unpack one template directory out of a repository archive.
1213
+ *
1214
+ * Contents are written exactly as they were recorded — a template is a real
1215
+ * project, so nothing here rewrites, re-encodes or substitutes anything. The
1216
+ * whole archive is validated before the first write, so a refused entry leaves
1217
+ * the destination as it found it rather than half-scaffolded.
1218
+ *
1219
+ * Returns the paths written, relative to `destination`.
1220
+ */
1221
+ function extractTemplateDirectory(archive, options) {
1222
+ let tar;
1223
+ try {
1224
+ tar = zlib.gunzipSync(archive);
1225
+ } catch {
1226
+ throw new ArchiveError("The downloaded file is not a gzip archive.");
1227
+ }
1228
+ const { directory, destination } = options;
1229
+ const root = path.resolve(destination);
1230
+ const planned = [];
1231
+ for (const entry of readEntries(tar)) {
1232
+ if (path.posix.isAbsolute(entry.path) || /^[A-Za-z]:/.test(entry.path)) throw new ArchiveError(`The archive contains an absolute path ('${entry.path}').`);
1233
+ const stripped = withoutArchiveRoot(entry.path);
1234
+ if (stripped === null) continue;
1235
+ const relative = withinDirectory(stripped, directory);
1236
+ if (relative === null || relative === "") continue;
1237
+ const absolute = path.resolve(root, relative);
1238
+ if (absolute !== root && !absolute.startsWith(root + path.sep)) throw new ArchiveError(`The archive contains a path outside the project ('${entry.path}').`);
1239
+ planned.push({
1240
+ absolute,
1241
+ relative,
1242
+ entry
1243
+ });
1244
+ }
1245
+ if (planned.length === 0) throw new ArchiveError(`The archive contains no directory named '${directory}'.`);
1246
+ for (const { absolute, entry } of planned) {
1247
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
1248
+ fs.writeFileSync(absolute, entry.body, { mode: entry.mode & 511 || 420 });
1249
+ }
1250
+ return planned.map(({ relative }) => relative);
1251
+ }
1252
+ //#endregion
1253
+ //#region src/templates.ts
1254
+ /**
1255
+ * Read the template catalogue.
1256
+ *
1257
+ * Deliberately unauthenticated: discovery has to work for someone who has not
1258
+ * signed up yet, so no credential is read and none is sent — a stale token file
1259
+ * left over from a previous install cannot turn this into a rejection.
1260
+ */
1261
+ async function fetchTemplateCatalog(gatewayUrl) {
1262
+ let res;
1263
+ try {
1264
+ res = await fetch(`${gatewayUrl}/v1/templates`);
1265
+ } catch (err) {
1266
+ throw new Error(`Could not reach ${gatewayUrl} to list templates. ${err instanceof Error ? err.message : err}`);
1267
+ }
1268
+ if (!res.ok) throw new Error(`${gatewayUrl} could not list templates (HTTP ${res.status}).`);
1269
+ let data;
1270
+ try {
1271
+ data = await res.json();
1272
+ } catch {
1273
+ throw new Error(`${gatewayUrl} answered with something that is not a template list.`);
1274
+ }
1275
+ if (!Array.isArray(data.templates)) throw new Error(`${gatewayUrl} answered with something that is not a template list.`);
1276
+ return data.templates;
1277
+ }
1278
+ /**
1279
+ * Find one template by the name a user typed after `--template`.
1280
+ *
1281
+ * Resolved against the catalogue rather than a list compiled in here, so a
1282
+ * template added yesterday is scaffoldable by a CLI released last year. An
1283
+ * unknown name answers with the names that do exist: the mistake is almost
1284
+ * always a typo, and the fix is on screen.
1285
+ */
1286
+ async function resolveTemplate(gatewayUrl, name) {
1287
+ const templates = await fetchTemplateCatalog(gatewayUrl);
1288
+ const match = templates.find((template) => template.name === name);
1289
+ if (match) return match;
1290
+ if (templates.length === 0) throw new Error(`No templates are available from ${gatewayUrl} yet.`);
1291
+ const available = templates.map((template) => template.name).join(", ");
1292
+ throw new Error(`No template called '${name}'. Available templates: ${available}.`);
1293
+ }
1294
+ /**
1295
+ * Download a template's archive.
1296
+ *
1297
+ * From the archive host the catalogue named, rather than the REST API's tarball
1298
+ * endpoint: that one is rate-limited per IP for unauthenticated callers, so
1299
+ * behind corporate NAT or on a shared runner the very first thing a new user
1300
+ * does would fail intermittently, for a reason they cannot diagnose.
1301
+ */
1302
+ async function downloadTemplateArchive(archiveUrl) {
1303
+ let res;
1304
+ try {
1305
+ res = await fetch(archiveUrl);
1306
+ } catch (err) {
1307
+ throw new Error(`Could not download the template from ${archiveUrl}. ${errorText(err)}`);
1308
+ }
1309
+ if (!res.ok) throw new Error(`Could not download the template from ${archiveUrl} (HTTP ${res.status}). The template's stable ref may not have been published yet.`);
1310
+ return Buffer.from(await res.arrayBuffer());
1311
+ }
1312
+ /** List every template in the catalogue. */
1313
+ async function listTemplates(options) {
1314
+ const gatewayUrl = getGatewayUrl(options.api);
1315
+ if (options.verbose) console.log(`[wawesome:verbose] GET ${gatewayUrl}/v1/templates`);
1316
+ let templates;
1317
+ try {
1318
+ templates = await fetchTemplateCatalog(gatewayUrl);
1319
+ } catch (err) {
1320
+ console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
1321
+ process.exit(1);
1322
+ return;
1323
+ }
1324
+ if (templates.length === 0) {
1325
+ console.log("[wawesome] No templates available yet.");
1326
+ return;
1327
+ }
1328
+ console.log("\n📦 \x1B[1mTemplates\x1B[0m\n");
1329
+ for (const template of templates) {
1330
+ console.log(` \x1b[36m${template.name}\x1b[0m — ${template.title}`);
1331
+ console.log(` ${template.description}`);
1332
+ if (template.tags.length > 0) console.log(` \x1b[90m${template.tags.join(", ")}\x1b[0m`);
1333
+ console.log(` \x1b[90m${template.source_url}\x1b[0m`);
1334
+ if (options.verbose) console.log(` \x1b[90mref ${template.ref} · ${template.archive_url}\x1b[0m`);
1335
+ console.log("");
1336
+ }
1337
+ }
1338
+ /** Dispatch for `wawesome templates [action]`. */
1339
+ async function templatesCommand(action, options) {
1340
+ if (!action || action === "list" || action === "ls") return listTemplates(options);
1341
+ console.error(`[wawesome] Error: Unknown templates action '${action}'. Available actions: list (alias: ls).`);
1342
+ process.exit(1);
1343
+ }
1344
+ //#endregion
1345
+ //#region src/init.ts
1346
+ /** Bound on the re-prompt loop, so unusable input exits instead of looping. */
1347
+ const MAX_SLUG_ATTEMPTS = 5;
1348
+ /**
1349
+ * Ask for a name that becomes part of the public URL, until the answer is one.
1350
+ *
1351
+ * Both names this asks for — the App the Function belongs to, and the Function
1352
+ * itself — end up in the address a client reads, so neither may be taken as
1353
+ * typed. An illegal answer is re-offered as a suggestion rather than rewritten:
1354
+ * the user sees the name they will get before agreeing to it.
1355
+ */
1356
+ async function promptForSlug(session, label, proposed) {
1357
+ let suggestion = sanitizeSlug(proposed);
1358
+ for (let attempt = 0; attempt < MAX_SLUG_ATTEMPTS; attempt++) {
1359
+ const answer = await session.ask(` ${label}?`, suggestion);
1360
+ if (isLegalSlug(answer)) return answer;
1361
+ const sanitized = sanitizeSlug(answer);
1362
+ if (sanitized) {
1363
+ console.log(`[wawesome] '${answer}' can't be used as ${anArticle(label)}. Suggested: '${sanitized}'.`);
1364
+ suggestion = sanitized;
1365
+ } else console.log(`[wawesome] ${anArticle(label, true)} needs lowercase letters or numbers, separated by single hyphens.`);
1366
+ }
1367
+ console.error(`[wawesome] Error: no usable ${label} after ${MAX_SLUG_ATTEMPTS} attempts. Nothing was written.`);
1368
+ console.error(`[wawesome] Run 'wawesome init' again once you know what to call it.`);
1369
+ process.exit(1);
1370
+ }
1371
+ /** "an App slug" / "A Function name" — so one prompt reads for both names. */
1372
+ function anArticle(label, capitalized = false) {
1373
+ const article = /^[aeiou]/i.test(label) ? "an" : "a";
1374
+ return `${capitalized ? article[0].toUpperCase() + article.slice(1) : article} ${label}`;
1375
+ }
1376
+ /** Report a failure the user can act on, and stop. */
1377
+ function fail(message, ...advice) {
1378
+ console.error(`[wawesome] Error: ${message}`);
1379
+ for (const line of advice) console.error(`[wawesome] ${line}`);
1380
+ process.exit(1);
1381
+ throw new Error(message);
1382
+ }
1383
+ /**
1384
+ * Ask for every variable the template declared.
1385
+ *
1386
+ * The pointer to where the value lives is shown with the question, because the
1387
+ * user is being asked for a value that exists in somebody else's dashboard and
1388
+ * hunting for it in third-party documentation is where a first deploy stalls.
1389
+ * A blank answer is allowed even for a required variable: for a webhook secret
1390
+ * the value does not exist yet — the endpoint has to be created first — so
1391
+ * refusing to continue would be refusing the only order that works.
1392
+ */
1393
+ async function promptForDeclaredEnv(session, declared) {
1394
+ if (declared.length === 0) return [];
1395
+ console.log("\n[wawesome] This template needs a few values before it can run.\n");
1396
+ const answers = [];
1397
+ for (const variable of declared) {
1398
+ const flags = [variable.required ? "required" : "optional"];
1399
+ if (variable.secret) flags.push("stored write-only");
1400
+ console.log(` \x1b[1m${variable.key}\x1b[0m \x1b[90m(${flags.join(", ")})\x1b[0m`);
1401
+ console.log(` ${variable.description}`);
1402
+ console.log(` \x1b[90mWhere to find it: ${variable.where_to_find}\x1b[0m`);
1403
+ const value = await session.ask(` ${variable.key}?`, "");
1404
+ console.log("");
1405
+ answers.push({
1406
+ declared: variable,
1407
+ value
1408
+ });
1409
+ }
1410
+ return answers;
1411
+ }
1412
+ /**
1413
+ * Whether the stored session is one the platform still accepts.
1414
+ *
1415
+ * Only a refusal counts as an answer. An unreachable gateway says nothing about
1416
+ * whether the credentials are good, and treating it as a dead session would send
1417
+ * a user who is merely offline through a login they do not need.
1418
+ */
1419
+ async function sessionIsLive(creds) {
1420
+ try {
1421
+ await fetchTenantDetails(creds);
1422
+ return true;
1423
+ } catch (err) {
1424
+ return !(err instanceof GatewayError && (err.status === 401 || err.status === 403));
1425
+ }
1426
+ }
1427
+ /**
1428
+ * Get a working session before the first question is asked.
1429
+ *
1430
+ * The credentials file existing is not the same as being logged in — a JWT
1431
+ * expires — and everything this command does after scaffolding needs a live
1432
+ * session. Checked here, the two answers a user can get are "log in now" and a
1433
+ * project that scaffolds without one. Checked where it used to be, at the first
1434
+ * write, the answer was an error *after* they had typed a webhook secret in.
1435
+ *
1436
+ * Returns null when there is no session to work with, which is a supported way
1437
+ * to finish: the files are still worth having.
1438
+ */
1439
+ async function ensureSession(options) {
1440
+ const stored = readCredentials();
1441
+ if (stored && await sessionIsLive(stored)) return stored;
1442
+ console.log(stored ? "\n[wawesome] Your session has expired." : "\n[wawesome] You're not logged in yet — that's what a deploy needs.");
1443
+ if (!isInteractive()) {
1444
+ console.log("[wawesome] Run 'wawesome login', then 'wawesome deploy'.");
1445
+ return null;
1446
+ }
1447
+ let wantsLogin;
1448
+ try {
1449
+ wantsLogin = await confirm({
1450
+ message: "Log in now?",
1451
+ default: true
1452
+ });
1453
+ } catch {
1454
+ console.log("[wawesome] Cancelled. Nothing was written.");
1455
+ process.exit(130);
1456
+ }
1457
+ if (!wantsLogin) return null;
1458
+ try {
1459
+ await login({
1460
+ api: options.api,
1461
+ verbose: options.verbose
1462
+ });
1463
+ } catch (err) {
1464
+ console.error(`[wawesome] Login failed: ${errorText(err)}`);
1465
+ return null;
1466
+ }
1467
+ return readCredentials();
1468
+ }
1469
+ /**
1470
+ * Satisfy the template's declarations against the platform.
1471
+ *
1472
+ * The manifest declares; nothing here is template-specific. Each declaration
1473
+ * goes through an endpoint the platform already ships, which is what keeps a new
1474
+ * template from needing a CLI release.
1475
+ */
1476
+ async function wireUp(creds, appSlug, manifest, answers) {
1477
+ const scaffolded = "Your project is scaffolded — fix this, then run 'wawesome deploy'.";
1478
+ try {
1479
+ await ensureApp(creds, appSlug);
1480
+ } catch (err) {
1481
+ fail(errorText(err), scaffolded);
1482
+ }
1483
+ for (const { declared, value } of answers) {
1484
+ if (!value) {
1485
+ const secretFlag = declared.secret ? " --secret" : "";
1486
+ console.log(`[wawesome] ${declared.key} left blank. Set it with: wawesome env set ${declared.key} <value>${secretFlag}`);
1487
+ continue;
1488
+ }
1489
+ try {
1490
+ await putEnvVar(creds, appSlug, declared.key, value, declared.secret);
1491
+ } catch (err) {
1492
+ fail(errorText(err), scaffolded);
1493
+ }
1494
+ console.log(`[wawesome] ✅ Stored ${declared.key}${declared.secret ? " (secret)" : ""}.`);
1495
+ }
1496
+ for (const provider of manifest.providers) {
1497
+ try {
1498
+ await enableEgressProvider(creds, appSlug, provider);
1499
+ } catch (err) {
1500
+ fail(errorText(err), scaffolded);
1501
+ }
1502
+ console.log(`[wawesome] ✅ Enabled outbound calls to '${provider}'.`);
1503
+ }
1504
+ }
1505
+ /**
1506
+ * Scaffold from a catalogue template, wire up what it declared, and deploy it.
1507
+ *
1508
+ * The template arrives as a repository archive over plain HTTP — no git, which
1509
+ * makes this work on a machine that has never had it installed, and on Windows.
1510
+ * It is unpacked into a staging directory first, so a template that turns out to
1511
+ * collide with what is already here leaves the directory exactly as it found it.
1512
+ */
1513
+ async function initFromTemplate(templateName, options) {
1514
+ const isVerbose = Boolean(options.verbose);
1515
+ const gatewayUrl = getGatewayUrl(options.api);
1516
+ const cwd = process.cwd();
1517
+ const dirName = path.basename(cwd);
1518
+ let template;
1519
+ try {
1520
+ template = await resolveTemplate(gatewayUrl, templateName);
1521
+ } catch (err) {
1522
+ fail(errorText(err));
1523
+ }
1524
+ console.log(`[wawesome] Fetching ${template.title} (${template.ref})...`);
1525
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${template.archive_url}`);
1526
+ let archive;
1527
+ try {
1528
+ archive = await downloadTemplateArchive(template.archive_url);
1529
+ } catch (err) {
1530
+ fail(errorText(err));
1531
+ }
1532
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), "wawesome-template-"));
1533
+ const sweepStaging = () => fs.rmSync(staging, {
1534
+ recursive: true,
1535
+ force: true
1536
+ });
1537
+ process.once("exit", sweepStaging);
1538
+ let functionName;
1539
+ let appSlug;
1540
+ let manifest;
1541
+ let creds;
1542
+ let answers = [];
1543
+ try {
1544
+ let files;
1545
+ try {
1546
+ files = extractTemplateDirectory(archive, {
1547
+ directory: template.directory,
1548
+ destination: staging
1549
+ });
1550
+ manifest = readTemplateManifest(staging);
1551
+ } catch (err) {
1552
+ fail(errorText(err));
1553
+ }
1554
+ const collisions = collidingPaths(cwd, files);
1555
+ if (collisions.length > 0) fail(`${collisions.join(", ")} already exist${collisions.length === 1 ? "s" : ""} here.`, "Run this in an empty directory, or move those files aside first. Nothing was written.");
1556
+ creds = await ensureSession(options);
1557
+ const session = openPromptSession();
1558
+ try {
1559
+ functionName = await promptForSlug(session, "Function name", readFunctionConfig(staging)?.function || dirName);
1560
+ console.log(" (an App groups the Functions of one project — its slug is part of the public URL)");
1561
+ appSlug = await promptForSlug(session, "App slug", dirName);
1562
+ let repinned = null;
1563
+ try {
1564
+ fs.cpSync(staging, cwd, { recursive: true });
1565
+ applyTemplateIdentity(cwd, {
1566
+ app: appSlug,
1567
+ functionName
1568
+ });
1569
+ repinned = alignCliDependency(cwd, CLI_VERSION);
1570
+ } catch (err) {
1571
+ fail(errorText(err), `Some of '${template.name}' may have been written to ${cwd}.`);
1572
+ }
1573
+ console.log(`\n[wawesome] ✅ Scaffolded ${files.length} files from '${template.name}'.`);
1574
+ if (repinned) console.log(`[wawesome] Using wawesome ${repinned.to} — the template pinned ${repinned.from}.`);
1575
+ answers = creds ? await promptForDeclaredEnv(session, manifest.env) : [];
1576
+ } finally {
1577
+ session.close();
1578
+ }
1579
+ } finally {
1580
+ sweepStaging();
1581
+ process.off("exit", sweepStaging);
1582
+ }
1583
+ if (!creds) {
1584
+ const installed = options.install === false ? null : installDependencies(cwd, { verbose: options.verbose });
1585
+ console.log("\n[wawesome] 🎉 Project ready. Next steps:");
1586
+ [
1587
+ ...installed?.ok ? [] : [installed?.command ?? "npm install"],
1588
+ "wawesome login",
1589
+ "wawesome deploy"
1590
+ ].forEach((step, index) => console.log(` ${index + 1}. ${step}`));
1591
+ console.log("");
1592
+ return;
1593
+ }
1594
+ await wireUp(creds, appSlug, manifest, answers);
1595
+ if (options.install !== false) installDependencies(cwd, { verbose: options.verbose });
1596
+ const result = await deploy(void 0, {
1597
+ out: "dist/index.js",
1598
+ verbose: options.verbose
1599
+ });
1600
+ if (manifest.post_deploy) {
1601
+ console.log(renderPostDeploy(manifest.post_deploy, result.invokeUrl));
1602
+ console.log("");
1603
+ }
1604
+ }
1605
+ /**
1606
+ * Scaffold a new wawesome function project in the current directory.
1607
+ */
1608
+ async function init(options) {
1609
+ if (options.template) return initFromTemplate(options.template, options);
1610
+ const isVerbose = Boolean(options.verbose);
1611
+ const cwd = process.cwd();
1612
+ const dirName = path.basename(cwd);
1613
+ console.log("[wawesome] Initializing a new function project...\n");
1614
+ const session = openPromptSession();
1615
+ let functionName;
1616
+ let appSlug;
1617
+ try {
1618
+ functionName = await promptForSlug(session, "Function name", dirName);
1619
+ console.log(" (an App groups the Functions of one project — its slug is part of the public URL)");
1620
+ appSlug = await promptForSlug(session, "App slug", dirName);
1621
+ } finally {
1622
+ session.close();
1623
+ }
1624
+ const entry = "src/index.ts";
1625
+ if (isVerbose) console.log(`[wawesome:verbose] function=${functionName}, app=${appSlug}, entry=${entry}`);
1626
+ const configContent = {
1627
+ app: appSlug,
1628
+ function: functionName,
1629
+ entry
1630
+ };
1631
+ const configPath = path.join(cwd, "wawesome-function.json");
1632
+ if (fs.existsSync(configPath)) console.log("[wawesome] wawesome-function.json already exists, skipping.");
1633
+ else {
1634
+ fs.writeFileSync(configPath, JSON.stringify(configContent, null, 2) + "\n", "utf-8");
1635
+ console.log("[wawesome] ✅ Created wawesome-function.json");
1636
+ }
1637
+ const srcDir = path.join(cwd, "src");
1638
+ const indexPath = path.join(srcDir, "index.ts");
1639
+ if (fs.existsSync(indexPath)) console.log("[wawesome] src/index.ts already exists, skipping.");
1640
+ else {
1641
+ fs.mkdirSync(srcDir, { recursive: true });
1642
+ fs.writeFileSync(indexPath, `/**
1643
+ * Serverless Function Handler
1644
+ * Standard HTTP fetch request handler
1645
+ */
1646
+ export default {
1647
+ async fetch(request: Request): Promise<Response> {
1648
+ return Response.json({
1649
+ message: "Hello, World!",
1650
+ });
1651
+ },
1652
+ };
1653
+ `, "utf-8");
1654
+ console.log("[wawesome] ✅ Created src/index.ts");
1655
+ }
1656
+ const pkgPath = path.join(cwd, "package.json");
1657
+ if (fs.existsSync(pkgPath)) console.log("[wawesome] package.json already exists, skipping.");
1658
+ else {
1659
+ const pkgContent = {
1660
+ name: functionName,
1661
+ type: "module",
1662
+ version: "1.0.0",
1663
+ description: `Serverless function: ${functionName}`,
1664
+ scripts: {
1665
+ build: "wawesome build",
1666
+ deploy: "wawesome deploy",
1667
+ typecheck: "tsc --noEmit"
1668
+ },
1669
+ devDependencies: {
1670
+ "wawesome": `^${CLI_VERSION}`,
1671
+ typescript: "^7.0.0"
1672
+ }
1673
+ };
1674
+ fs.writeFileSync(pkgPath, JSON.stringify(pkgContent, null, 2) + "\n", "utf-8");
1675
+ console.log("[wawesome] ✅ Created package.json");
1676
+ }
1677
+ const tsconfigPath = path.join(cwd, "tsconfig.json");
1678
+ if (fs.existsSync(tsconfigPath)) console.log("[wawesome] tsconfig.json already exists, skipping.");
1679
+ else {
1680
+ fs.writeFileSync(tsconfigPath, JSON.stringify({
1681
+ compilerOptions: {
1682
+ target: "ES2022",
1683
+ module: "ESNext",
1684
+ moduleResolution: "bundler",
1685
+ strict: true,
1686
+ noEmit: true,
1687
+ lib: [
1688
+ "ES2022",
1689
+ "DOM",
1690
+ "DOM.Iterable"
1691
+ ]
1692
+ },
1693
+ include: ["src"]
1694
+ }, null, 2) + "\n", "utf-8");
1695
+ console.log("[wawesome] ✅ Created tsconfig.json");
1696
+ }
1697
+ const gitignorePath = path.join(cwd, ".gitignore");
1698
+ if (fs.existsSync(gitignorePath)) console.log("[wawesome] .gitignore already exists, skipping.");
1699
+ else {
1700
+ fs.writeFileSync(gitignorePath, "node_modules\ndist\n", "utf-8");
1701
+ console.log("[wawesome] ✅ Created .gitignore");
1702
+ }
1703
+ const installed = options.install === false ? null : installDependencies(cwd, { verbose: options.verbose });
1704
+ console.log("\n[wawesome] 🎉 Project initialized! Next steps:");
1705
+ [
1706
+ ...installed?.ok ? [] : [installed?.command ?? "npm install"],
1707
+ "wawesome login",
1708
+ "wawesome deploy"
1709
+ ].forEach((step, index) => console.log(` ${index + 1}. ${step}`));
1710
+ console.log("");
1711
+ }
1712
+ //#endregion
1713
+ //#region src/version.ts
1714
+ async function fetchVersions(gatewayUrl, tenantJwt, app, funcName, env, isVerbose) {
1715
+ const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/versions?environment=${encodeURIComponent(env)}`;
1716
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
1717
+ const res = await fetch(url, {
1718
+ method: "GET",
1719
+ headers: { Authorization: `Bearer ${tenantJwt}` }
1720
+ });
1721
+ if (!res.ok) {
1722
+ const errorBody = await res.text();
1723
+ if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
1724
+ else {
1725
+ console.error(`[wawesome] Error: Failed to list versions (HTTP ${res.status}).`);
1726
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
1727
+ }
1728
+ process.exit(1);
1729
+ }
1730
+ return await res.json();
1731
+ }
1732
+ /**
1733
+ * List existing function versions.
1734
+ */
1735
+ async function listVersions(options) {
1736
+ const isVerbose = Boolean(options.verbose);
1737
+ const env = options.env || "production";
1738
+ const creds = readCredentials();
1739
+ if (!creds) {
1740
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
1741
+ process.exit(1);
1742
+ }
1743
+ const config = readFunctionConfig();
1744
+ if (!config) {
1745
+ console.error("[wawesome] Error: No wawesome-function.json found in the current directory.");
1746
+ process.exit(1);
1747
+ }
1748
+ const { app, function: funcName } = config;
1749
+ const data = await fetchVersions(creds.gateway_url, creds.tenant_jwt, app, funcName, env, isVerbose);
1750
+ if (!data.versions || data.versions.length === 0) {
1751
+ console.log(`[wawesome] No versions found for '${app}/${funcName}'. Deploy code first using 'wawesome deploy'.`);
792
1752
  return;
793
1753
  }
794
1754
  console.log(`\n📦 \x1b[1mVersions for '${app}/${funcName}' (environment: ${env})\x1b[0m\n`);
@@ -894,177 +1854,6 @@ async function switchVersion(targetInput, options) {
894
1854
  console.log(`======================================================\n`);
895
1855
  }
896
1856
  //#endregion
897
- //#region src/env.ts
898
- const STANDARD_SECRET_MESSAGES = [
899
- "Encrypted at rest using AES-256",
900
- "This value can't be viewed again after you save it — only updated or deleted",
901
- "Only decrypted at the moment your function runs — never returned by the CLI, dashboard, or API after creation."
902
- ];
903
- /**
904
- * Load credentials and validate project config.
905
- */
906
- function loadClientContext() {
907
- const creds = readCredentials();
908
- if (!creds) {
909
- console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
910
- process.exit(1);
911
- }
912
- const config = readFunctionConfig();
913
- if (!config || !config.app || !config.app.trim()) {
914
- console.error("[wawesome] Error: No wawesome-function.json found in the current directory.");
915
- console.error("[wawesome] Run 'wawesome init' to create one, or create it manually.");
916
- process.exit(1);
917
- }
918
- return {
919
- creds,
920
- app: config.app.trim()
921
- };
922
- }
923
- /**
924
- * Set or overwrite an environment variable on the current app.
925
- */
926
- async function setEnvVar(key, value, options) {
927
- const isVerbose = Boolean(options.verbose);
928
- if (!key || value === void 0) {
929
- console.error("[wawesome] Error: Missing key or value. Usage: wawesome env set <KEY> <VALUE> [--secret]");
930
- process.exit(1);
931
- }
932
- const { creds, app } = loadClientContext();
933
- const isSecret = Boolean(options.secret);
934
- const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
935
- if (isVerbose) {
936
- console.log(`[wawesome:verbose] POST ${url}`);
937
- console.log(`[wawesome:verbose] Payload: key=${key}, is_secret=${isSecret}`);
938
- }
939
- const res = await fetch(url, {
940
- method: "POST",
941
- headers: {
942
- Authorization: `Bearer ${creds.tenant_jwt}`,
943
- "Content-Type": "application/json"
944
- },
945
- body: JSON.stringify({
946
- key,
947
- value,
948
- is_secret: isSecret
949
- })
950
- });
951
- if (!res.ok) {
952
- const errorBody = await res.text();
953
- if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
954
- else {
955
- let msg = `HTTP ${res.status}`;
956
- try {
957
- const parsed = JSON.parse(errorBody);
958
- if (parsed.error) msg = parsed.error;
959
- } catch {}
960
- console.error(`[wawesome] Error: Failed to set variable '${key}' (${msg}).`);
961
- if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
962
- }
963
- process.exit(1);
964
- }
965
- const data = await res.json();
966
- if (data.variable.is_secret || isSecret) {
967
- console.log(`\n[wawesome] ✅ Set secret variable '${key}' on app '${app}'.`);
968
- const messages = data.notice?.messages?.length ? data.notice.messages : STANDARD_SECRET_MESSAGES;
969
- console.log("\n🔒 \x1B[1mSecret Notice:\x1B[0m");
970
- for (const msg of messages) console.log(` • ${msg}`);
971
- console.log("");
972
- } else console.log(`[wawesome] ✅ Set variable '${key}' on app '${app}'.`);
973
- }
974
- function truncate(str, maxLen) {
975
- if (str.length <= maxLen) return str;
976
- if (maxLen <= 3) return str.slice(0, maxLen);
977
- return str.slice(0, maxLen - 1) + "…";
978
- }
979
- /**
980
- * List environment variables on the current app.
981
- */
982
- async function listEnvVars(options) {
983
- const isVerbose = Boolean(options.verbose);
984
- const { creds, app } = loadClientContext();
985
- const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
986
- if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
987
- const res = await fetch(url, {
988
- method: "GET",
989
- headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
990
- });
991
- if (!res.ok) {
992
- const errorBody = await res.text();
993
- if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
994
- else {
995
- console.error(`[wawesome] Error: Failed to list variables (HTTP ${res.status}).`);
996
- if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
997
- }
998
- process.exit(1);
999
- }
1000
- const data = await res.json();
1001
- if (!data.variables || data.variables.length === 0) {
1002
- console.log(`[wawesome] No environment variables set for app '${app}'.`);
1003
- return;
1004
- }
1005
- const availableWidth = (process.stdout.columns && process.stdout.columns > 40 ? process.stdout.columns : 80) - 12;
1006
- let rawMaxKeyLen = 15;
1007
- let rawMaxValLen = 15;
1008
- for (const item of data.variables) {
1009
- if (item.key.length > rawMaxKeyLen) rawMaxKeyLen = item.key.length;
1010
- if (item.value.length > rawMaxValLen) rawMaxValLen = item.value.length;
1011
- }
1012
- const maxKeyLen = Math.min(rawMaxKeyLen, Math.max(15, Math.floor(availableWidth * .4)));
1013
- const maxValLen = Math.min(rawMaxValLen, Math.max(15, availableWidth - maxKeyLen));
1014
- console.log(`\n📋 \x1b[1mEnvironment variables for '${app}'\x1b[0m\n`);
1015
- const headerKey = "KEY".padEnd(maxKeyLen);
1016
- const headerVal = "VALUE".padEnd(maxValLen);
1017
- console.log(`${headerKey} | ${headerVal} | SECRET`);
1018
- console.log(`${"-".repeat(maxKeyLen)}-|- ${"-".repeat(maxValLen)} |-------`);
1019
- for (const item of data.variables) {
1020
- const truncatedKey = truncate(item.key, maxKeyLen);
1021
- const truncatedVal = truncate(item.value, maxValLen);
1022
- const keyStr = truncatedKey.padEnd(maxKeyLen);
1023
- const valStr = truncatedVal.padEnd(maxValLen);
1024
- const secretStr = item.is_secret ? "\x1B[33mtrue\x1B[0m" : "false";
1025
- console.log(`${keyStr} | ${valStr} | ${secretStr}`);
1026
- }
1027
- console.log("");
1028
- }
1029
- /**
1030
- * Delete an environment variable on the current app.
1031
- */
1032
- async function removeEnvVar(key, options) {
1033
- const isVerbose = Boolean(options.verbose);
1034
- if (!key) {
1035
- console.error("[wawesome] Error: Missing variable key. Usage: wawesome env rm <KEY>");
1036
- process.exit(1);
1037
- }
1038
- const { creds, app } = loadClientContext();
1039
- const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env/${encodeURIComponent(key)}`;
1040
- if (isVerbose) console.log(`[wawesome:verbose] DELETE ${url}`);
1041
- const res = await fetch(url, {
1042
- method: "DELETE",
1043
- headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
1044
- });
1045
- if (!res.ok) {
1046
- const errorBody = await res.text();
1047
- if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
1048
- else if (res.status === 404) console.error(`[wawesome] Error: Variable '${key}' not found on app '${app}'.`);
1049
- else {
1050
- console.error(`[wawesome] Error: Failed to delete variable '${key}' (HTTP ${res.status}).`);
1051
- if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
1052
- }
1053
- process.exit(1);
1054
- }
1055
- console.log(`[wawesome] ✅ Deleted variable '${key}' from app '${app}'.`);
1056
- }
1057
- /**
1058
- * Dispatcher function for `wawesome env` CLI command.
1059
- */
1060
- async function envCommand(action, key, value, options) {
1061
- if (!action || action === "list" || action === "ls") return listEnvVars(options);
1062
- if (action === "set") return setEnvVar(key, value, options);
1063
- if (action === "rm" || action === "remove" || action === "delete" || action === "unset") return removeEnvVar(key, options);
1064
- console.error(`[wawesome] Error: Unknown env action '${action}'. Available actions: list, set, rm.`);
1065
- process.exit(1);
1066
- }
1067
- //#endregion
1068
1857
  //#region src/logs.ts
1069
1858
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1070
1859
  function isUuid(str) {
@@ -1669,7 +2458,8 @@ cli.command("login", "Authenticate with the wawesome.io platform").option("--api
1669
2458
  cli.command("logout", "Clear stored authentication credentials").action(() => logout());
1670
2459
  cli.command("whoami", "Show current login session info").action(() => whoami());
1671
2460
  cli.command("workspace [action] [name]", "Show the workspace, or rename its public address").usage("workspace <action> [name]\n\nActions:\n show Show the workspace name, address, and whether it can still change\n rename <name> Change the public address, while nothing live depends on it").example("wawesome workspace").example("wawesome workspace rename northwind").option("-v, --verbose", "Enable verbose debug output").action((action, name, options) => workspaceCommand(action, name, options));
1672
- cli.command("init", "Scaffold a new function project in the current directory").option("-v, --verbose", "Enable verbose debug output").action((options) => init(options));
2461
+ cli.command("templates [action]", "Browse the template catalog").usage("templates [action]\n\nActions:\n list (ls) Show every available template (default)").example("wawesome templates").example("wawesome templates list").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("-v, --verbose", "Enable verbose debug output").action((action, options) => templatesCommand(action, options));
2462
+ cli.command("init", "Scaffold a new function project in the current directory").usage("init [options]\n\nWith --template, the project is fetched from the template catalog, wired up\nfrom what the template declares it needs, and deployed. Run 'wawesome templates'\nto see what is available.").example("wawesome init").example("wawesome init --template stripe-webhook").option("-t, --template <name>", "Scaffold from a catalog template and deploy it").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--no-install", "Skip installing dependencies after scaffolding").option("-v, --verbose", "Enable verbose debug output").action((options) => init(options));
1673
2463
  cli.command("logs [function-name-or-invocation-id]", "View invocation history, fetch log output, or follow live").usage(`logs [target] [options]
1674
2464
 
1675
2465
  The target argument determines what the command does:
@@ -1692,7 +2482,7 @@ cli.command("logs [function-name-or-invocation-id]", "View invocation history, f
1692
2482
  invocations — new output appears each time the function runs, no need to catch a
1693
2483
  specific invocation. Press Ctrl-C to stop at any time.`).option("-f, --follow", "Stream live output (tail -f style). Follows a running invocation or waits for the next one. Ctrl-C to stop").option("-i, --invocation <id>", "Fetch stdout/stderr log body for a specific invocation ID").option("-a, --app <app>", "App slug override (defaults to wawesome-function.json)").option("-s, --status <status>", "Filter invocations by status (success, error, timeout, running)").option("--success", "Shorthand for --status success").option("--error", "Shorthand for --status error").option("--timeout", "Shorthand for --status timeout").option("--running", "Shorthand for --status running").option("-v, --verbose", "Enable verbose debug output").action((target, options) => logsCommand(target, options));
1694
2484
  cli.help();
1695
- cli.version("1.0.0");
2485
+ cli.version(CLI_VERSION);
1696
2486
  cli.parse();
1697
2487
  //#endregion
1698
2488
  export {};