@toggld/js 0.0.0-stage → 0.9.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/dist/cli.mjs ADDED
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ import { l as apiUrl, o as decodeSnapshot } from "./wire-CrkBeqnM.mjs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ // src/internal/cli.ts
5
+ /**
6
+ * The `toggld` CLI (ADR-0013). Today it has one command:
7
+ *
8
+ * toggld pull [--sdk-key <key>] [--api <url>] [--out <path>] [--env-name <name>] [--force]
9
+ *
10
+ * `pull` does exactly what a sync-mode SDK does on bootstrap — `GET
11
+ * /sync/v1/flags` with the SDK key — then writes the validated `FlagSnapshot` to
12
+ * disk as a local flag file (the wire snapshot plus a small provenance
13
+ * envelope). Point a file-mode client at it for offline evaluation.
14
+ *
15
+ * This module is side-effect-free on import: {@link runPull} performs the fetch
16
+ * and write, and {@link runMain} parses argv and prints. The executable entry
17
+ * (`../cli.ts`) is a one-line wrapper. Keeping the logic here lets tests call it
18
+ * directly instead of spawning a process.
19
+ */
20
+ const SCHEMA_URL = "https://toggld.dev/schema/local-flags.v1.json";
21
+ const DEFAULT_OUT = "./toggld.flags.json";
22
+ /** Hosted Toggld. To go through a proxy, pass `--api` or set `TOGGLD_API_URL`. */
23
+ const DEFAULT_ENDPOINT = "https://api.toggld.io";
24
+ /** A user-facing failure: printed as a clean message, never a stack trace. */
25
+ var CliError = class extends Error {
26
+ /** An optional follow-up line with remediation guidance. */
27
+ hint;
28
+ constructor(message, hint) {
29
+ super(message);
30
+ this.name = "CliError";
31
+ this.hint = hint;
32
+ }
33
+ };
34
+ const normalizeEndpoint = (endpoint) => endpoint.replace(/\/+$/, "");
35
+ /** The version already on disk at `out`, if it is a readable local flag file. */
36
+ const existingVersion = async (out) => {
37
+ try {
38
+ const parsed = JSON.parse(await readFile(out, "utf8"));
39
+ return typeof parsed.version === "string" ? parsed.version : void 0;
40
+ } catch {
41
+ return;
42
+ }
43
+ };
44
+ /**
45
+ * Fetch the environment's current snapshot for `sdkKey` and write it to `out` as
46
+ * a local flag file. Idempotent: an unchanged version is a no-op unless `force`.
47
+ */
48
+ const runPull = async (options) => {
49
+ const fetchImpl = options.fetch ?? globalThis.fetch;
50
+ const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
51
+ const endpoint = normalizeEndpoint(options.endpoint);
52
+ let url;
53
+ try {
54
+ url = apiUrl(options.endpoint, "/sync/v1/flags").toString();
55
+ } catch {
56
+ url = `${endpoint}/sync/v1/flags`;
57
+ }
58
+ let response;
59
+ try {
60
+ response = await fetchImpl(url, { headers: {
61
+ authorization: `Bearer ${options.sdkKey}`,
62
+ accept: "application/json"
63
+ } });
64
+ } catch (error) {
65
+ throw new CliError(`Could not reach the flag API at ${endpoint}: ${describe(error)}`, "Check --api / TOGGLD_API_URL and that the server is running.");
66
+ }
67
+ if (response.status === 401) throw new CliError("The flag API rejected the SDK key (401 Unauthorized).", "Check the key.");
68
+ if (response.status === 403) throw new CliError("This key cannot read flag definitions (403 Forbidden).", "`pull` needs a secret SDK key (ffk_…); public browser tokens (ffp_…) can't read definitions.");
69
+ if (!response.ok) throw new CliError(`The flag API returned ${response.status} ${response.statusText}.`);
70
+ const body = await response.json().catch(() => {
71
+ throw new CliError("The flag API response was not valid JSON.");
72
+ });
73
+ let snapshot;
74
+ try {
75
+ snapshot = decodeSnapshot(body);
76
+ } catch {
77
+ throw new CliError("The flag API response did not match the expected snapshot format.");
78
+ }
79
+ if (!options.force && await existingVersion(options.out) === snapshot.version) return {
80
+ out: options.out,
81
+ version: snapshot.version,
82
+ flagCount: snapshot.flags.length,
83
+ segmentCount: snapshot.segments?.length ?? 0,
84
+ unchanged: true
85
+ };
86
+ const envelope = {
87
+ $schema: SCHEMA_URL,
88
+ toggld: {
89
+ ...options.envName ? { environment: options.envName } : {},
90
+ apiBase: endpoint,
91
+ generatedAt: now(),
92
+ generatedBy: "toggld pull"
93
+ },
94
+ version: snapshot.version,
95
+ flags: snapshot.flags,
96
+ ...snapshot.segments ? { segments: snapshot.segments } : {}
97
+ };
98
+ await writeFile(options.out, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
99
+ return {
100
+ out: options.out,
101
+ version: snapshot.version,
102
+ flagCount: snapshot.flags.length,
103
+ segmentCount: snapshot.segments?.length ?? 0,
104
+ unchanged: false
105
+ };
106
+ };
107
+ const describe = (e) => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" ? e.message : String(e);
108
+ const USAGE = `toggld — Toggld feature-flag CLI
109
+
110
+ Usage:
111
+ toggld pull [options] Fetch an environment's flags into a local file
112
+
113
+ Options for "pull":
114
+ -k, --sdk-key <key> Secret SDK key (ffk_…). Falls back to TOGGLD_SDK_KEY,
115
+ then prompts. Required.
116
+ -a, --api <url> Flag API base URL. Falls back to TOGGLD_API_URL
117
+ (or the older TOGGLD_FLAG_API_URL), then
118
+ ${DEFAULT_ENDPOINT}.
119
+ -o, --out <path> Output file. Default ${DEFAULT_OUT}.
120
+ --env-name <name> Label recorded in the file's provenance envelope.
121
+ -f, --force Rewrite even if the file already has this version.
122
+ -h, --help Show this help.
123
+
124
+ Evaluate from the file with { mode: "file", file: "<path>" }, or set
125
+ TOGGLD_FLAGS_FILE to redirect any client to it.`;
126
+ const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
127
+ "force",
128
+ "f",
129
+ "help",
130
+ "h"
131
+ ]);
132
+ const ALIASES = {
133
+ k: "sdk-key",
134
+ a: "api",
135
+ o: "out",
136
+ f: "force",
137
+ h: "help"
138
+ };
139
+ const parseArgs = (argv) => {
140
+ const flags = {};
141
+ let command;
142
+ for (let i = 0; i < argv.length; i++) {
143
+ const arg = argv[i];
144
+ if (!arg.startsWith("-")) {
145
+ command ??= arg;
146
+ continue;
147
+ }
148
+ const eq = arg.indexOf("=");
149
+ const rawName = (eq === -1 ? arg : arg.slice(0, eq)).replace(/^-+/, "");
150
+ const name = ALIASES[rawName] ?? rawName;
151
+ const canonical = ALIASES[rawName] ?? rawName;
152
+ if (BOOLEAN_FLAGS.has(rawName) || BOOLEAN_FLAGS.has(canonical)) {
153
+ flags[name] = true;
154
+ continue;
155
+ }
156
+ if (eq !== -1) flags[name] = arg.slice(eq + 1);
157
+ else flags[name] = argv[++i] ?? "";
158
+ }
159
+ return {
160
+ command,
161
+ flags
162
+ };
163
+ };
164
+ /** Read a secret without echoing it, from an interactive terminal. */
165
+ const promptSecret = async (label) => {
166
+ if (!process.stdin.isTTY) throw new CliError("No SDK key provided.", "Pass --sdk-key, set TOGGLD_SDK_KEY, or run in an interactive terminal.");
167
+ const { createInterface } = await import("node:readline");
168
+ const rl = createInterface({
169
+ input: process.stdin,
170
+ output: process.stdout,
171
+ terminal: true
172
+ });
173
+ const internals = rl;
174
+ const write = internals.output.write.bind(internals.output);
175
+ let muted = false;
176
+ internals._writeToOutput = (s) => {
177
+ if (!muted || s.includes(label)) write(s);
178
+ };
179
+ try {
180
+ const answer = await new Promise((resolve) => {
181
+ rl.question(label, resolve);
182
+ muted = true;
183
+ });
184
+ write("\n");
185
+ return answer.trim();
186
+ } finally {
187
+ rl.close();
188
+ }
189
+ };
190
+ /**
191
+ * Parse argv, run the requested command, and return a process exit code. Prints
192
+ * results to stdout and {@link CliError}s to stderr; unexpected errors rethrow.
193
+ */
194
+ const runMain = async (argv) => {
195
+ const { command, flags } = parseArgs(argv);
196
+ if (flags["help"] || command === void 0 || command === "help") {
197
+ process.stdout.write(`${USAGE}\n`);
198
+ return command === void 0 && !flags["help"] ? 1 : 0;
199
+ }
200
+ if (command !== "pull") {
201
+ process.stderr.write(`Unknown command "${command}".\n\n${USAGE}\n`);
202
+ return 1;
203
+ }
204
+ try {
205
+ const endpoint = asString(flags["api"]) ?? (process.env.TOGGLD_API_URL || void 0) ?? (process.env.TOGGLD_FLAG_API_URL || void 0) ?? DEFAULT_ENDPOINT;
206
+ const out = asString(flags["out"]) ?? DEFAULT_OUT;
207
+ const envName = asString(flags["env-name"]);
208
+ const sdkKey = asString(flags["sdk-key"]) ?? (process.env.TOGGLD_SDK_KEY || void 0) ?? await promptSecret("Toggld SDK key: ");
209
+ if (!sdkKey) throw new CliError("No SDK key provided.");
210
+ const result = await runPull({
211
+ sdkKey,
212
+ endpoint,
213
+ out,
214
+ force: Boolean(flags["force"]),
215
+ ...envName ? { envName } : {}
216
+ });
217
+ if (result.unchanged) process.stdout.write(`✓ ${result.out} already up to date (version ${short(result.version)}).\n`);
218
+ else {
219
+ const segs = result.segmentCount > 0 ? `, ${result.segmentCount} segments` : "";
220
+ process.stdout.write(`✓ Wrote ${result.out} — ${result.flagCount} flags${segs} (version ${short(result.version)}).\n`);
221
+ }
222
+ return 0;
223
+ } catch (error) {
224
+ if (error instanceof CliError) {
225
+ process.stderr.write(`✗ ${error.message}\n`);
226
+ if (error.hint) process.stderr.write(` ${error.hint}\n`);
227
+ return 1;
228
+ }
229
+ throw error;
230
+ }
231
+ };
232
+ const asString = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
233
+ const short = (version) => version.length > 12 ? `${version.slice(0, 12)}…` : version;
234
+ // src/cli.ts
235
+ /**
236
+ * The `toggld` executable. All logic lives in `./internal/cli.ts` (so tests can
237
+ * call it without spawning a process); this is just the entry that runs it.
238
+ */
239
+ runMain(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
240
+ console.error(error);
241
+ process.exit(1);
242
+ });
243
+ export {};