@quonfig/cli 0.0.51 → 0.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -44
- package/dist/commands/run.js +0 -1
- package/dist/commands/serve.d.ts +23 -0
- package/dist/commands/serve.js +136 -0
- package/dist/migrate/sources/flagsmith/translate.js +46 -0
- package/dist/migrate/sources/flagsmith.js +0 -20
- package/dist/quonfig.js +0 -1
- package/dist/serve/datadir-discovery.d.ts +33 -0
- package/dist/serve/datadir-discovery.js +38 -0
- package/dist/serve/handlers/evalContext.d.ts +66 -0
- package/dist/serve/handlers/evalContext.js +170 -0
- package/dist/serve/server.d.ts +53 -0
- package/dist/serve/server.js +355 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +116 -1
- package/package.json +2 -2
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server for `qfg serve`. Boots a single `@quonfig/node` Quonfig client
|
|
3
|
+
* with `{datadir, dataDirAutoReload}` so file changes flow into a shared
|
|
4
|
+
* snapshot (mirror of the SDK's `ConfigStore`). All requests evaluate against
|
|
5
|
+
* that snapshot — see plan project/plans/qfg-serve.md §5 ("Integration with
|
|
6
|
+
* auto-reload work").
|
|
7
|
+
*
|
|
8
|
+
* Bound by default to 127.0.0.1. Auth and CORS are configured per plan §4.
|
|
9
|
+
*/
|
|
10
|
+
import * as crypto from 'node:crypto';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as http from 'node:http';
|
|
13
|
+
import { ConfigStore, Evaluator, Quonfig } from '@quonfig/node';
|
|
14
|
+
import { handleEvalContext } from './handlers/evalContext.js';
|
|
15
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
16
|
+
const isLoopback = (host) => LOOPBACK_HOSTS.has(host);
|
|
17
|
+
export async function startServer(opts) {
|
|
18
|
+
// Loopback policy — plan §4 auth note. Refuse non-loopback binds without
|
|
19
|
+
// an explicit opt-in; this prevents the developer from accidentally
|
|
20
|
+
// exposing an unauthenticated server to their LAN.
|
|
21
|
+
const policyHost = opts.forceNonLoopbackForTest ?? opts.host;
|
|
22
|
+
if (!isLoopback(policyHost) && !opts.allowNonLoopback) {
|
|
23
|
+
throw new Error(`Refusing to bind ${opts.host}: pass --allow-non-loopback to confirm the server should be reachable from the LAN.`);
|
|
24
|
+
}
|
|
25
|
+
if (!isLoopback(policyHost) && opts.allowNonLoopback) {
|
|
26
|
+
opts.logger.warn(`qfg serve is bound to ${opts.host}: any device on your LAN can reach it. ` +
|
|
27
|
+
`Anyone with the URL gets the datadir contents; do not run this on an untrusted network ` +
|
|
28
|
+
`without --frontend-sdk-key.`);
|
|
29
|
+
}
|
|
30
|
+
// Canonicalize the datadir to its long-name absolute path before handing
|
|
31
|
+
// it to the SDK. On Windows, os.tmpdir() and short-name paths like
|
|
32
|
+
// `C:\Users\RUNNER~1\...` trip a libuv assertion in fs-event.c when
|
|
33
|
+
// fs.watch compares the registered dir against incoming event filenames
|
|
34
|
+
// (the input is the short name, the events come back as long names).
|
|
35
|
+
// realpathSync.native delegates to uv_fs_realpath, which returns the same
|
|
36
|
+
// canonical form libuv uses internally — so the assertion never fires.
|
|
37
|
+
// No-op on POSIX where short names don't exist.
|
|
38
|
+
const datadir = (() => {
|
|
39
|
+
try {
|
|
40
|
+
return fs.realpathSync.native(opts.datadir);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return opts.datadir;
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
// The Quonfig client owns datadir loading + file watching via the
|
|
47
|
+
// `dataDirAutoReload` option that landed in sdk-node 0.0.30 (qfg-zx3y).
|
|
48
|
+
// We hold a parallel ConfigStore + Evaluator that mirror the client's
|
|
49
|
+
// internal state so the eval handler can call evaluator.evaluateConfig()
|
|
50
|
+
// directly — Quonfig's public surface (`get`, `rawConfig`, `getRawMatch`)
|
|
51
|
+
// doesn't return the typed Value object the EvalEnvelope shape needs.
|
|
52
|
+
let store = new ConfigStore();
|
|
53
|
+
let evaluator = new Evaluator(store);
|
|
54
|
+
const environment = opts.environment;
|
|
55
|
+
let version = '';
|
|
56
|
+
const refreshSnapshot = (client) => {
|
|
57
|
+
// Pull every config out of the client and stuff it into our parallel
|
|
58
|
+
// store. `keys()` and `rawConfig()` are public; they return the
|
|
59
|
+
// ConfigResponse exactly as the client loaded it from disk.
|
|
60
|
+
//
|
|
61
|
+
// NOTE: the SDK fires `onConfigUpdate` from inside its initial
|
|
62
|
+
// `loadLocalData()` call, BEFORE `init()` has flipped `initialized` to
|
|
63
|
+
// true. Calling `client.keys()` during that window would throw "Not
|
|
64
|
+
// initialized." We swallow that one case here — the post-init manual
|
|
65
|
+
// refresh covers the initial load.
|
|
66
|
+
let keys;
|
|
67
|
+
try {
|
|
68
|
+
keys = client.keys();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const configs = [];
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const cfg = client.rawConfig(key);
|
|
76
|
+
if (cfg)
|
|
77
|
+
configs.push(cfg);
|
|
78
|
+
}
|
|
79
|
+
// G8 (qfg-38sf.6): version is a content hash of the canonicalized
|
|
80
|
+
// configs, not the absolute datadir path. The old shape leaked operator
|
|
81
|
+
// filesystem layout on every response and changed on every reload even
|
|
82
|
+
// when the data was unchanged, defeating ETag-style caching.
|
|
83
|
+
version = computeVersion(configs);
|
|
84
|
+
const envelope = {
|
|
85
|
+
configs,
|
|
86
|
+
meta: { version, environment },
|
|
87
|
+
};
|
|
88
|
+
store = new ConfigStore();
|
|
89
|
+
evaluator = new Evaluator(store);
|
|
90
|
+
store.loadFromDatafile(envelope);
|
|
91
|
+
};
|
|
92
|
+
// qfg-zx3y's auto-reload fires `onConfigUpdate` after the SDK has installed
|
|
93
|
+
// the new envelope. We re-sync our snapshot from the client at that point.
|
|
94
|
+
const client = new Quonfig({
|
|
95
|
+
sdkKey: 'qfg-serve-local',
|
|
96
|
+
datadir,
|
|
97
|
+
environment: opts.environment,
|
|
98
|
+
dataDirAutoReload: opts.watch,
|
|
99
|
+
dataDirAutoReloadDebounceMs: opts.watchDebounceMs,
|
|
100
|
+
// Telemetry is intentionally OFF — qfg serve has no Quonfig backend to
|
|
101
|
+
// post to, and the SDK's default-on collectors would just buffer forever.
|
|
102
|
+
collectEvaluationSummaries: false,
|
|
103
|
+
contextUploadMode: 'none',
|
|
104
|
+
enableSSE: false,
|
|
105
|
+
fallbackPollEnabled: false,
|
|
106
|
+
onConfigUpdate() {
|
|
107
|
+
refreshSnapshot(client);
|
|
108
|
+
if (opts.verbose)
|
|
109
|
+
opts.logger.log(`qfg-serve: reloaded datadir (version=${version})`);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
await client.init();
|
|
113
|
+
// Initial snapshot — the SDK fires onConfigUpdate during init() so this is
|
|
114
|
+
// mostly insurance against an init path that doesn't, but it's cheap.
|
|
115
|
+
if (store.keys().length === 0) {
|
|
116
|
+
refreshSnapshot(client);
|
|
117
|
+
}
|
|
118
|
+
// CORS resolution per request. Three shapes:
|
|
119
|
+
// - `*` anywhere in the list → wildcard (no Vary)
|
|
120
|
+
// - exactly one origin → fixed value (no Vary; caller pinned it)
|
|
121
|
+
// - 2+ origins → allow-list: echo the request Origin if it matches and emit
|
|
122
|
+
// `Vary: Origin`; omit the header entirely on a miss (qfg-38sf.5). The
|
|
123
|
+
// CORS spec does not permit a comma-joined Access-Control-Allow-Origin,
|
|
124
|
+
// so the previous `.join(', ')` was silently breaking browser clients.
|
|
125
|
+
const corsAllowList = new Set(opts.corsOrigins);
|
|
126
|
+
const corsMode = corsAllowList.has('*')
|
|
127
|
+
? 'wildcard'
|
|
128
|
+
: opts.corsOrigins.length <= 1
|
|
129
|
+
? 'fixed'
|
|
130
|
+
: 'allowlist';
|
|
131
|
+
const fixedCorsOrigin = corsMode === 'fixed' ? (opts.corsOrigins[0] ?? '*') : null;
|
|
132
|
+
// qfg-38sf.1 security audit F3: the sendToClientSdk filter is UNCONDITIONAL.
|
|
133
|
+
// Every qfg serve consumer is by definition a frontend client (browser /
|
|
134
|
+
// react-native — server SDKs use datadir mode directly). There is no flag
|
|
135
|
+
// that turns this off. See evalContext.ts for the filter itself.
|
|
136
|
+
// Bump maxHeaderSize so that oversized base64 context tokens reach our
|
|
137
|
+
// application-level 414 handler rather than getting bounced by Node's
|
|
138
|
+
// default 16KB header parser. Our app cap is 64KB (see evalContext.ts G6
|
|
139
|
+
// check); 256KB leaves room for the path plus normal Authorization /
|
|
140
|
+
// Cookie / Accept headers.
|
|
141
|
+
const server = http.createServer({ maxHeaderSize: 256 * 1024 }, async (req, res) => {
|
|
142
|
+
const startedAt = Date.now();
|
|
143
|
+
// CORS — emit on every response, including 401 and 404, so browser
|
|
144
|
+
// clients surface the real status code instead of an opaque CORS error.
|
|
145
|
+
if (corsMode === 'wildcard') {
|
|
146
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
147
|
+
}
|
|
148
|
+
else if (corsMode === 'fixed' && fixedCorsOrigin !== null) {
|
|
149
|
+
res.setHeader('Access-Control-Allow-Origin', fixedCorsOrigin);
|
|
150
|
+
}
|
|
151
|
+
else if (corsMode === 'allowlist') {
|
|
152
|
+
const reqOrigin = req.headers.origin;
|
|
153
|
+
// Vary: Origin regardless of match — caches must not reuse a
|
|
154
|
+
// matched-Origin response for a request from a different Origin.
|
|
155
|
+
res.setHeader('Vary', 'Origin');
|
|
156
|
+
if (typeof reqOrigin === 'string' && corsAllowList.has(reqOrigin)) {
|
|
157
|
+
res.setHeader('Access-Control-Allow-Origin', reqOrigin);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
161
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
162
|
+
res.setHeader('Access-Control-Expose-Headers', 'ETag');
|
|
163
|
+
if (req.method === 'OPTIONS') {
|
|
164
|
+
res.writeHead(204);
|
|
165
|
+
res.end();
|
|
166
|
+
logRequest(req, res, startedAt, opts);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Auth — plan §4. If --frontend-sdk-key was set, every non-OPTIONS
|
|
170
|
+
// request must present `Authorization: Basic base64("1:<key>")`. If it
|
|
171
|
+
// wasn't set, no check.
|
|
172
|
+
if (opts.frontendSdkKey) {
|
|
173
|
+
const ok = checkBasicAuth(req.headers.authorization, opts.frontendSdkKey);
|
|
174
|
+
if (!ok) {
|
|
175
|
+
res.writeHead(401, {
|
|
176
|
+
'Content-Type': 'text/plain',
|
|
177
|
+
'WWW-Authenticate': 'Basic realm="qfg-serve"',
|
|
178
|
+
});
|
|
179
|
+
res.end('unauthorized');
|
|
180
|
+
logRequest(req, res, startedAt, opts);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const url = req.url ?? '/';
|
|
185
|
+
const [pathOnly] = url.split('?');
|
|
186
|
+
// GET /api/v2/configs/eval-with-context/{base64url}
|
|
187
|
+
const evalPrefix = '/api/v2/configs/eval-with-context/';
|
|
188
|
+
if (req.method === 'GET' && pathOnly.startsWith(evalPrefix)) {
|
|
189
|
+
const ctxToken = pathOnly.slice(evalPrefix.length);
|
|
190
|
+
if (!ctxToken) {
|
|
191
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
192
|
+
res.end('missing context token');
|
|
193
|
+
logRequest(req, res, startedAt, opts);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
await handleEvalContext(req, res, ctxToken, {
|
|
198
|
+
getSnapshot: () => ({ store, evaluator, environment, version }),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
opts.logger.warn(`qfg-serve: eval-with-context handler threw: ${error.message}`);
|
|
203
|
+
if (!res.headersSent) {
|
|
204
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
205
|
+
res.end('internal error');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
logRequest(req, res, startedAt, opts);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// POST /api/v1/telemetry/ → 404 with a helpful body. Plan §2: we
|
|
212
|
+
// intentionally don't accept telemetry — silently 200-OK-and-dropping
|
|
213
|
+
// would hide a misconfig.
|
|
214
|
+
if (req.method === 'POST' && pathOnly === '/api/v1/telemetry/') {
|
|
215
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
216
|
+
res.end(JSON.stringify({
|
|
217
|
+
error: 'qfg serve does not accept telemetry',
|
|
218
|
+
hint: 'Disable client-side telemetry by passing `collectEvaluationSummaries: false` ' +
|
|
219
|
+
'(and `contextUploadMode: "none"`) to your SDK init, or point the SDK\'s ' +
|
|
220
|
+
'telemetryUrl at a real Quonfig telemetry endpoint.',
|
|
221
|
+
}));
|
|
222
|
+
logRequest(req, res, startedAt, opts);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
226
|
+
res.end('not found');
|
|
227
|
+
logRequest(req, res, startedAt, opts);
|
|
228
|
+
});
|
|
229
|
+
// Bind. EADDRINUSE is the only common failure here; surface it with a
|
|
230
|
+
// pointed message so the developer doesn't have to read a stack trace.
|
|
231
|
+
await new Promise((resolve, reject) => {
|
|
232
|
+
const onError = (err) => {
|
|
233
|
+
server.removeListener('listening', onListening);
|
|
234
|
+
if (err.code === 'EADDRINUSE') {
|
|
235
|
+
reject(new Error(`Port ${opts.port} is already in use. Pass --port <n> to pick another.`));
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
reject(err);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const onListening = () => {
|
|
242
|
+
server.removeListener('error', onError);
|
|
243
|
+
resolve();
|
|
244
|
+
};
|
|
245
|
+
server.once('error', onError);
|
|
246
|
+
server.once('listening', onListening);
|
|
247
|
+
server.listen(opts.port, opts.host);
|
|
248
|
+
});
|
|
249
|
+
const boundPort = server.address().port;
|
|
250
|
+
let closed = false;
|
|
251
|
+
const close = async () => {
|
|
252
|
+
if (closed)
|
|
253
|
+
return;
|
|
254
|
+
closed = true;
|
|
255
|
+
await new Promise((resolve) => {
|
|
256
|
+
server.close(() => resolve());
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
await client.close();
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
/* best effort */
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
return { port: boundPort, host: opts.host, close };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* G8 (qfg-38sf.6): compute meta.version as sha256 of the canonicalized
|
|
269
|
+
* configs array. Canonical form is `JSON.stringify(configs, sortedKeys)` so
|
|
270
|
+
* two snapshots with identical config content produce the same hash regardless
|
|
271
|
+
* of object-key order. The hash replaces the prior `datadir:<abs-path>#<ts>`
|
|
272
|
+
* shape, which leaked operator filesystem layout and changed on every reload
|
|
273
|
+
* even when the data was unchanged.
|
|
274
|
+
*
|
|
275
|
+
* Exported for unit tests that want to assert determinism / stability.
|
|
276
|
+
*/
|
|
277
|
+
export function computeVersion(configs) {
|
|
278
|
+
const canonical = JSON.stringify(configs, sortedKeyReplacer);
|
|
279
|
+
return `sha256:${crypto.createHash('sha256').update(canonical, 'utf8').digest('hex')}`;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Replacer for JSON.stringify that emits object keys in sorted order. Arrays
|
|
283
|
+
* keep their original order (array order is semantically meaningful for rules
|
|
284
|
+
* and weighted-values).
|
|
285
|
+
*/
|
|
286
|
+
function sortedKeyReplacer(_key, value) {
|
|
287
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
288
|
+
const sorted = {};
|
|
289
|
+
for (const k of Object.keys(value).sort()) {
|
|
290
|
+
sorted[k] = value[k];
|
|
291
|
+
}
|
|
292
|
+
return sorted;
|
|
293
|
+
}
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Validate `Authorization: Basic base64("1:<key>")` against the configured
|
|
298
|
+
* SDK key. The browser SDKs build this header in
|
|
299
|
+
* `sdk-javascript/src/apiHelpers.ts:7`.
|
|
300
|
+
*
|
|
301
|
+
* G4: use `crypto.timingSafeEqual` instead of `===`. The frontend SDK key is
|
|
302
|
+
* publicly-available by design (plan §4) so this isn't a hard security
|
|
303
|
+
* boundary, but `timingSafeEqual` is the right default — cheap, correct, and
|
|
304
|
+
* removes a class of timing-leak nits from any future audit.
|
|
305
|
+
*
|
|
306
|
+
* Length check first because `timingSafeEqual` throws on mismatched buffer
|
|
307
|
+
* lengths; that throw is itself a timing side-channel (and an exception path).
|
|
308
|
+
*/
|
|
309
|
+
function checkBasicAuth(header, expectedKey) {
|
|
310
|
+
if (!header || !header.toLowerCase().startsWith('basic '))
|
|
311
|
+
return false;
|
|
312
|
+
const b64 = header.slice('basic '.length).trim();
|
|
313
|
+
let decoded;
|
|
314
|
+
try {
|
|
315
|
+
decoded = Buffer.from(b64, 'base64').toString('utf8');
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
const expected = `1:${expectedKey}`;
|
|
321
|
+
const decodedBuf = Buffer.from(decoded, 'utf8');
|
|
322
|
+
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
323
|
+
if (decodedBuf.length !== expectedBuf.length)
|
|
324
|
+
return false;
|
|
325
|
+
return crypto.timingSafeEqual(decodedBuf, expectedBuf);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Verbose-mode request log. G1 (qfg-38sf.1 audit): redact the base64-encoded
|
|
329
|
+
* context token from the URL — it contains caller PII (email, user id,
|
|
330
|
+
* arbitrary attributes). Format matches api-delivery's LoggingMiddleware
|
|
331
|
+
* shape: `METHOD PATH STATUS DURATION_MS`. We elide the ctx segment so the
|
|
332
|
+
* path collapses to e.g.
|
|
333
|
+
* `GET /api/v2/configs/eval-with-context/<ctx-redacted> 200 3ms`
|
|
334
|
+
* which still tells the developer which route fired without leaking the
|
|
335
|
+
* payload.
|
|
336
|
+
*/
|
|
337
|
+
function logRequest(req, res, startedAt, opts) {
|
|
338
|
+
if (!opts.verbose)
|
|
339
|
+
return;
|
|
340
|
+
const method = req.method ?? '-';
|
|
341
|
+
const rawUrl = req.url ?? '/';
|
|
342
|
+
const [pathOnly, query] = rawUrl.split('?', 2);
|
|
343
|
+
const evalPrefix = '/api/v2/configs/eval-with-context/';
|
|
344
|
+
let safePath = pathOnly;
|
|
345
|
+
if (pathOnly.startsWith(evalPrefix)) {
|
|
346
|
+
safePath = `${evalPrefix}<ctx-redacted>`;
|
|
347
|
+
}
|
|
348
|
+
// Drop query string entirely — collectContextMode and friends are fine to
|
|
349
|
+
// surface but we have no allowlist parser today, so be conservative.
|
|
350
|
+
if (query && query.length > 0)
|
|
351
|
+
safePath += '?<redacted>';
|
|
352
|
+
const status = res.statusCode;
|
|
353
|
+
const duration = Date.now() - startedAt;
|
|
354
|
+
opts.logger.log(`qfg-serve: ${method} ${safePath} ${status} ${duration}ms`);
|
|
355
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "0.0.
|
|
1
|
+
declare const _default: "0.0.53";
|
|
2
2
|
export default _default;
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
|
|
2
|
-
export default '0.0.
|
|
2
|
+
export default '0.0.53';
|
package/oclif.manifest.json
CHANGED
|
@@ -2064,6 +2064,121 @@
|
|
|
2064
2064
|
"sdk-key.js"
|
|
2065
2065
|
]
|
|
2066
2066
|
},
|
|
2067
|
+
"serve": {
|
|
2068
|
+
"aliases": [],
|
|
2069
|
+
"args": {},
|
|
2070
|
+
"description": "Serve a local datadir over HTTP to browser/RN SDKs.\n\nReads configs from a local datadir and exposes them at\n`GET /api/v2/configs/eval-with-context/{base64url(ctx)}`. Point your\nbrowser SDK at the resulting URL (`http://localhost:6580` by default)\nand the same client code that talks to api-delivery in production will\nwork unmodified.\n\nTelemetry is intentionally not served — qfg serve has no Quonfig backend\nbehind it. Disable client-side telemetry with\n`collectEvaluationSummaries: false` (and `contextUploadMode: \"none\"`),\nor point the SDK's `telemetryUrl` at a real endpoint.\n\nThe server is bound to 127.0.0.1 by default; pass --allow-non-loopback\nto confirm a LAN-reachable bind.",
|
|
2071
|
+
"examples": [
|
|
2072
|
+
"<%= config.bin %> serve",
|
|
2073
|
+
"<%= config.bin %> serve --datadir ./our-config --environment production",
|
|
2074
|
+
"<%= config.bin %> serve --port 6581 --frontend-sdk-key PUBLIC_KEY"
|
|
2075
|
+
],
|
|
2076
|
+
"flags": {
|
|
2077
|
+
"json": {
|
|
2078
|
+
"description": "Format output as json.",
|
|
2079
|
+
"helpGroup": "GLOBAL",
|
|
2080
|
+
"name": "json",
|
|
2081
|
+
"allowNo": false,
|
|
2082
|
+
"type": "boolean"
|
|
2083
|
+
},
|
|
2084
|
+
"interactive": {
|
|
2085
|
+
"description": "Force interactive mode",
|
|
2086
|
+
"helpGroup": "GLOBAL",
|
|
2087
|
+
"name": "interactive",
|
|
2088
|
+
"allowNo": true,
|
|
2089
|
+
"type": "boolean"
|
|
2090
|
+
},
|
|
2091
|
+
"no-color": {
|
|
2092
|
+
"description": "Do not colorize output",
|
|
2093
|
+
"env": "NO_COLOR",
|
|
2094
|
+
"helpGroup": "GLOBAL",
|
|
2095
|
+
"name": "no-color",
|
|
2096
|
+
"allowNo": false,
|
|
2097
|
+
"type": "boolean"
|
|
2098
|
+
},
|
|
2099
|
+
"verbose": {
|
|
2100
|
+
"description": "Verbose output",
|
|
2101
|
+
"helpGroup": "GLOBAL",
|
|
2102
|
+
"name": "verbose",
|
|
2103
|
+
"allowNo": false,
|
|
2104
|
+
"type": "boolean"
|
|
2105
|
+
},
|
|
2106
|
+
"datadir": {
|
|
2107
|
+
"description": "Datadir to serve (defaults to ./our-config, then ./.quonfig, then errors). Honors QUONFIG_DIR.",
|
|
2108
|
+
"name": "datadir",
|
|
2109
|
+
"hasDynamicHelp": false,
|
|
2110
|
+
"multiple": false,
|
|
2111
|
+
"type": "option"
|
|
2112
|
+
},
|
|
2113
|
+
"environment": {
|
|
2114
|
+
"description": "Which environment slug to evaluate. Honors QUONFIG_ENVIRONMENT.",
|
|
2115
|
+
"name": "environment",
|
|
2116
|
+
"default": "development",
|
|
2117
|
+
"hasDynamicHelp": false,
|
|
2118
|
+
"multiple": false,
|
|
2119
|
+
"type": "option"
|
|
2120
|
+
},
|
|
2121
|
+
"port": {
|
|
2122
|
+
"description": "TCP port to listen on. Errors on collision; pass --port <n> to retry.",
|
|
2123
|
+
"name": "port",
|
|
2124
|
+
"default": 6580,
|
|
2125
|
+
"hasDynamicHelp": false,
|
|
2126
|
+
"multiple": false,
|
|
2127
|
+
"type": "option"
|
|
2128
|
+
},
|
|
2129
|
+
"host": {
|
|
2130
|
+
"description": "Bind address. Loopback by default; non-loopback requires --allow-non-loopback.",
|
|
2131
|
+
"name": "host",
|
|
2132
|
+
"default": "127.0.0.1",
|
|
2133
|
+
"hasDynamicHelp": false,
|
|
2134
|
+
"multiple": false,
|
|
2135
|
+
"type": "option"
|
|
2136
|
+
},
|
|
2137
|
+
"frontend-sdk-key": {
|
|
2138
|
+
"description": "If set, every request must present Authorization: Basic 1:<key>. If unset, the server is open (matches the \"datadir is the source of truth\" mental model).",
|
|
2139
|
+
"name": "frontend-sdk-key",
|
|
2140
|
+
"hasDynamicHelp": false,
|
|
2141
|
+
"multiple": false,
|
|
2142
|
+
"type": "option"
|
|
2143
|
+
},
|
|
2144
|
+
"cors-origin": {
|
|
2145
|
+
"description": "Allowed CORS origin (repeatable). Default is *; required when binding to a non-loopback host.",
|
|
2146
|
+
"name": "cors-origin",
|
|
2147
|
+
"default": [
|
|
2148
|
+
"*"
|
|
2149
|
+
],
|
|
2150
|
+
"hasDynamicHelp": false,
|
|
2151
|
+
"multiple": true,
|
|
2152
|
+
"type": "option"
|
|
2153
|
+
},
|
|
2154
|
+
"watch": {
|
|
2155
|
+
"description": "Reload the envelope when the datadir changes. --no-watch disables.",
|
|
2156
|
+
"name": "watch",
|
|
2157
|
+
"allowNo": true,
|
|
2158
|
+
"type": "boolean"
|
|
2159
|
+
},
|
|
2160
|
+
"allow-non-loopback": {
|
|
2161
|
+
"description": "Confirm a LAN-reachable bind. Required when --host is not loopback.",
|
|
2162
|
+
"name": "allow-non-loopback",
|
|
2163
|
+
"allowNo": false,
|
|
2164
|
+
"type": "boolean"
|
|
2165
|
+
}
|
|
2166
|
+
},
|
|
2167
|
+
"hasDynamicHelp": false,
|
|
2168
|
+
"hiddenAliases": [],
|
|
2169
|
+
"id": "serve",
|
|
2170
|
+
"pluginAlias": "@quonfig/cli",
|
|
2171
|
+
"pluginName": "@quonfig/cli",
|
|
2172
|
+
"pluginType": "core",
|
|
2173
|
+
"strict": true,
|
|
2174
|
+
"enableJsonFlag": true,
|
|
2175
|
+
"isESM": true,
|
|
2176
|
+
"relativePath": [
|
|
2177
|
+
"dist",
|
|
2178
|
+
"commands",
|
|
2179
|
+
"serve.js"
|
|
2180
|
+
]
|
|
2181
|
+
},
|
|
2067
2182
|
"set-default": {
|
|
2068
2183
|
"aliases": [
|
|
2069
2184
|
"toggle"
|
|
@@ -3623,5 +3738,5 @@
|
|
|
3623
3738
|
]
|
|
3624
3739
|
}
|
|
3625
3740
|
},
|
|
3626
|
-
"version": "0.0.
|
|
3741
|
+
"version": "0.0.53"
|
|
3627
3742
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
|
|
3
3
|
"name": "@quonfig/cli",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.53",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/quonfig/cli/issues"
|
|
7
7
|
},
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@oclif/core": "^3.18.2",
|
|
22
22
|
"@oclif/plugin-help": "^6",
|
|
23
23
|
"@oclif/plugin-plugins": "^4",
|
|
24
|
-
"@quonfig/node": "^0.0.
|
|
24
|
+
"@quonfig/node": "^0.0.30",
|
|
25
25
|
"acorn": "^8.10.0",
|
|
26
26
|
"acorn-walk": "^8.3.5",
|
|
27
27
|
"chalk": "^5.4.1",
|