@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/LICENSE +202 -0
- package/README.md +355 -2
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +243 -0
- package/dist/core-BQfyNOoT.mjs +921 -0
- package/dist/details-AiMdctXB.mjs +365 -0
- package/dist/effect.d.mts +17 -0
- package/dist/effect.mjs +75 -0
- package/dist/effectService-CGZnDaqS.d.mts +48 -0
- package/dist/effectService-DPD-fNCI.mjs +17 -0
- package/dist/index.d.mts +54 -0
- package/dist/index.mjs +127 -0
- package/dist/serverless-B_Vyg8Nd.mjs +259 -0
- package/dist/serverless-e__iCjpQ.d.mts +206 -0
- package/dist/serverless-effect.d.mts +51 -0
- package/dist/serverless-effect.mjs +121 -0
- package/dist/serverless.d.mts +3 -0
- package/dist/serverless.mjs +38 -0
- package/dist/types-BHn_vrQq.d.mts +211 -0
- package/dist/wire-CrkBeqnM.mjs +447 -0
- package/package.json +76 -3
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 {};
|