@quonfig/cli 0.0.52 → 0.0.54

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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `qfg serve` — Node-native local server that exposes a datadir over the
3
+ * same HTTP protocol the browser SDKs already speak. Designed to close the
4
+ * "browser SDKs can't read a local datadir" gap (epic qfg-38sf, plan
5
+ * project/plans/qfg-serve.md, Option A).
6
+ */
7
+ import type { JsonObj } from '../result.js';
8
+ import { BaseCommand } from '../index.js';
9
+ export default class Serve extends BaseCommand {
10
+ static description: string;
11
+ static examples: string[];
12
+ static flags: {
13
+ datadir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
14
+ environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
15
+ port: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
16
+ host: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
17
+ 'frontend-sdk-key': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
18
+ 'cors-origin': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[], import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
19
+ watch: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
20
+ 'allow-non-loopback': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
21
+ };
22
+ run(): Promise<JsonObj | void>;
23
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `qfg serve` — Node-native local server that exposes a datadir over the
3
+ * same HTTP protocol the browser SDKs already speak. Designed to close the
4
+ * "browser SDKs can't read a local datadir" gap (epic qfg-38sf, plan
5
+ * project/plans/qfg-serve.md, Option A).
6
+ */
7
+ import { Flags } from '@oclif/core';
8
+ import { BaseCommand } from '../index.js';
9
+ import { resolveDatadirForServe } from '../serve/datadir-discovery.js';
10
+ import { startServer } from '../serve/server.js';
11
+ export default class Serve extends BaseCommand {
12
+ static description = `Serve a local datadir over HTTP to browser/RN SDKs.
13
+
14
+ Reads configs from a local datadir and exposes them at
15
+ \`GET /api/v2/configs/eval-with-context/{base64url(ctx)}\`. Point your
16
+ browser SDK at the resulting URL (\`http://localhost:6580\` by default)
17
+ and the same client code that talks to api-delivery in production will
18
+ work unmodified.
19
+
20
+ Telemetry is intentionally not served — qfg serve has no Quonfig backend
21
+ behind it. Disable client-side telemetry with
22
+ \`collectEvaluationSummaries: false\` (and \`contextUploadMode: "none"\`),
23
+ or point the SDK's \`telemetryUrl\` at a real endpoint.
24
+
25
+ The server is bound to 127.0.0.1 by default; pass --allow-non-loopback
26
+ to confirm a LAN-reachable bind.`;
27
+ static examples = [
28
+ '<%= config.bin %> serve',
29
+ '<%= config.bin %> serve --datadir ./our-config --environment production',
30
+ '<%= config.bin %> serve --port 6581 --frontend-sdk-key PUBLIC_KEY',
31
+ ];
32
+ static flags = {
33
+ datadir: Flags.string({
34
+ description: 'Datadir to serve (defaults to ./our-config, then ./.quonfig, then errors). Honors QUONFIG_DIR.',
35
+ }),
36
+ environment: Flags.string({
37
+ default: 'development',
38
+ description: 'Which environment slug to evaluate. Honors QUONFIG_ENVIRONMENT.',
39
+ }),
40
+ port: Flags.integer({
41
+ default: 6580,
42
+ description: 'TCP port to listen on. Errors on collision; pass --port <n> to retry.',
43
+ }),
44
+ host: Flags.string({
45
+ default: '127.0.0.1',
46
+ description: 'Bind address. Loopback by default; non-loopback requires --allow-non-loopback.',
47
+ }),
48
+ 'frontend-sdk-key': Flags.string({
49
+ description: 'If set, every request must present Authorization: Basic 1:<key>. ' +
50
+ 'If unset, the server is open (matches the "datadir is the source of truth" mental model).',
51
+ }),
52
+ 'cors-origin': Flags.string({
53
+ default: ['*'],
54
+ description: 'Allowed CORS origin (repeatable). Default is *; required when binding to a non-loopback host.',
55
+ multiple: true,
56
+ }),
57
+ watch: Flags.boolean({
58
+ allowNo: true,
59
+ default: true,
60
+ description: 'Reload the envelope when the datadir changes. --no-watch disables.',
61
+ }),
62
+ 'allow-non-loopback': Flags.boolean({
63
+ default: false,
64
+ description: 'Confirm a LAN-reachable bind. Required when --host is not loopback.',
65
+ }),
66
+ };
67
+ async run() {
68
+ const { flags } = await this.parse(Serve);
69
+ const envEnvironment = process.env.QUONFIG_ENVIRONMENT;
70
+ const environment = flags.environment ?? envEnvironment ?? 'development';
71
+ const resolved = resolveDatadirForServe({
72
+ flagDatadir: flags.datadir,
73
+ envDatadir: process.env.QUONFIG_DIR,
74
+ cwd: process.cwd(),
75
+ });
76
+ if (resolved.kind === 'error') {
77
+ return this.err(resolved.message);
78
+ }
79
+ const datadir = resolved.dir;
80
+ if (this.isVerbose) {
81
+ this.verboseLog('Serve', { datadir, environment, port: flags.port, host: flags.host });
82
+ }
83
+ const logger = {
84
+ log: (msg) => this.log(msg),
85
+ warn: (msg) => this.logToStderr(msg),
86
+ };
87
+ let handle;
88
+ try {
89
+ handle = await startServer({
90
+ datadir,
91
+ environment,
92
+ port: flags.port,
93
+ host: flags.host,
94
+ frontendSdkKey: flags['frontend-sdk-key'],
95
+ corsOrigins: flags['cors-origin'],
96
+ watch: flags.watch,
97
+ allowNonLoopback: flags['allow-non-loopback'],
98
+ verbose: this.isVerbose,
99
+ logger,
100
+ });
101
+ }
102
+ catch (error) {
103
+ return this.err(error.message);
104
+ }
105
+ const url = `http://${flags.host}:${handle.port}`;
106
+ this.log(`qfg serve listening on ${url}`);
107
+ this.log(` datadir: ${datadir}`);
108
+ this.log(` environment: ${environment}`);
109
+ if (flags['frontend-sdk-key']) {
110
+ this.log(` auth: required (Authorization: Basic 1:<key>)`);
111
+ }
112
+ else {
113
+ this.log(` auth: none (datadir served openly)`);
114
+ }
115
+ this.log('');
116
+ this.log('Point your browser SDK at the URL above:');
117
+ this.log(` init({ apiUrls: ["${url}"] })`);
118
+ this.log('Press Ctrl-C to stop.');
119
+ // Block until the process is killed. The server is already accepting
120
+ // connections; we just need to keep the event loop alive.
121
+ await new Promise((resolve) => {
122
+ const shutdown = async () => {
123
+ this.log('\nqfg serve shutting down...');
124
+ try {
125
+ await handle.close();
126
+ }
127
+ finally {
128
+ resolve();
129
+ }
130
+ };
131
+ process.once('SIGINT', shutdown);
132
+ process.once('SIGTERM', shutdown);
133
+ });
134
+ return { datadir, environment, url };
135
+ }
136
+ }
@@ -136,7 +136,15 @@ export default class WorkspaceCreate extends BaseCommand {
136
136
  this.log(` Envs: ${created.environments.map((e) => e.name).join(', ')}`);
137
137
  this.log('');
138
138
  this.log(`To use this workspace locally:`);
139
- this.log(` qfg workspace switch ${created.workspaceSlug}`);
139
+ // `qfg workspace switch` requires the `<org-slug>/<workspace-slug>` pin
140
+ // form — bare slugs are rejected. Fall back to the interactive picker if
141
+ // the server did not return an org slug.
142
+ if (created.organizationSlug) {
143
+ this.log(` qfg workspace switch ${created.organizationSlug}/${created.workspaceSlug}`);
144
+ }
145
+ else {
146
+ this.log(` qfg workspace switch`);
147
+ }
140
148
  return {
141
149
  workspaceId: created.workspaceId,
142
150
  workspaceSlug: created.workspaceSlug,
@@ -94,7 +94,7 @@ export interface BuildAuditFinalCommitOptions {
94
94
  }
95
95
  /**
96
96
  * qfg-wbkj: build the final state-file commit for audit-log mode. Writes
97
- * `.qf/import-state.json` + `MIGRATION_REPORT.md` with cumulative counts read
97
+ * `.qf/import-state.json` + `.qf/MIGRATION_REPORT.md` with cumulative counts read
98
98
  * from disk, and surfaces the source's accumulated dropped/skipped/coerced
99
99
  * summaries via `onAccumulatorUpdate`. The `preWrite` / `postWrite` hooks let
100
100
  * push-to-cloud merge environments and run server-side-equivalent validation
@@ -301,7 +301,7 @@ export const buildAuditPerChangeCommits = (changes, source) => changes.map((chan
301
301
  });
302
302
  /**
303
303
  * qfg-wbkj: build the final state-file commit for audit-log mode. Writes
304
- * `.qf/import-state.json` + `MIGRATION_REPORT.md` with cumulative counts read
304
+ * `.qf/import-state.json` + `.qf/MIGRATION_REPORT.md` with cumulative counts read
305
305
  * from disk, and surfaces the source's accumulated dropped/skipped/coerced
306
306
  * summaries via `onAccumulatorUpdate`. The `preWrite` / `postWrite` hooks let
307
307
  * push-to-cloud merge environments and run server-side-equivalent validation
@@ -85,5 +85,16 @@ export interface MigrationReportData {
85
85
  */
86
86
  export declare const deriveFollowUpFromConversionNotes: (base: FollowUpChecklist, notes: ConversionNote[] | undefined) => FollowUpChecklist;
87
87
  export declare const buildMigrationReport: (data: MigrationReportData) => string;
88
+ /**
89
+ * Path of the migration report. It lives inside the `.qf/` bookkeeping
90
+ * directory (next to `import-state.json`), NOT the workspace root (qfg-a631).
91
+ *
92
+ * `qfg push` mirrors every non-dotfile on disk and the server's
93
+ * config-path-allowlist only permits `configs/`, `feature-flags/`, etc. plus
94
+ * `quonfig.json`/`README.md`. A root-level `MIGRATION_REPORT.md` got swept
95
+ * into the push and rejected ("Path not allowed by push allow-list"). Keeping
96
+ * it under the `.qf/` dotdir means the existing dotfile/dotdir skip in
97
+ * `bare-path-diff.ts` excludes it — no server change needed.
98
+ */
88
99
  export declare const migrationReportPath: (outputDir: string) => string;
89
100
  export declare const writeMigrationReport: (outputDir: string, data: MigrationReportData) => string;
@@ -565,10 +565,21 @@ export const buildMigrationReport = (data) => {
565
565
  }
566
566
  return sections.join('\n\n') + '\n';
567
567
  };
568
- export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');
568
+ /**
569
+ * Path of the migration report. It lives inside the `.qf/` bookkeeping
570
+ * directory (next to `import-state.json`), NOT the workspace root (qfg-a631).
571
+ *
572
+ * `qfg push` mirrors every non-dotfile on disk and the server's
573
+ * config-path-allowlist only permits `configs/`, `feature-flags/`, etc. plus
574
+ * `quonfig.json`/`README.md`. A root-level `MIGRATION_REPORT.md` got swept
575
+ * into the push and rejected ("Path not allowed by push allow-list"). Keeping
576
+ * it under the `.qf/` dotdir means the existing dotfile/dotdir skip in
577
+ * `bare-path-diff.ts` excludes it — no server change needed.
578
+ */
579
+ export const migrationReportPath = (outputDir) => path.join(outputDir, '.qf', 'MIGRATION_REPORT.md');
569
580
  export const writeMigrationReport = (outputDir, data) => {
570
- fs.mkdirSync(outputDir, { recursive: true });
571
581
  const filePath = migrationReportPath(outputDir);
582
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
572
583
  fs.writeFileSync(filePath, buildMigrationReport(data), 'utf8');
573
584
  return filePath;
574
585
  };
@@ -28,8 +28,8 @@ export interface PushMigrationToCloudOptions {
28
28
  * each commit authored as the original Launch user with their `changedAt` as
29
29
  * GIT_AUTHOR_DATE and `summary` as the message. One final state-file commit
30
30
  * (migrator identity, now) lands on top carrying .qf/import-state.json +
31
- * MIGRATION_REPORT.md so the audit-log commits aren't polluted by bookkeeping
32
- * churn. Requires that `source.getCommitMeta` be defined.
31
+ * .qf/MIGRATION_REPORT.md so the audit-log commits aren't polluted by
32
+ * bookkeeping churn. Requires that `source.getCommitMeta` be defined.
33
33
  */
34
34
  fullHistory?: boolean;
35
35
  importState: ImportState;
@@ -87,7 +87,13 @@ export interface CommitMeta {
87
87
  message: string;
88
88
  }
89
89
  export interface MigrationSource {
90
- fetchChanges(sinceEpochMs: null | number): AsyncIterable<LegacyChange>;
90
+ /**
91
+ * Yields the source's change history oldest→newest. `onProgress`, when
92
+ * provided, is called with the running fetched-change count as pagination
93
+ * proceeds, so the CLI can show progress during an otherwise-silent fetch.
94
+ * Sources that fetch in a single request may ignore it.
95
+ */
96
+ fetchChanges(sinceEpochMs: null | number, onProgress?: (fetched: number) => void): AsyncIterable<LegacyChange>;
91
97
  /**
92
98
  * Optional post-translate accumulator. Returns any rule values that translate()
93
99
  * coerced from a sentinel like Launch's empty-string "no value set yet" to the
@@ -4,4 +4,4 @@ export declare function selectLaunchBaseUrl(staging: boolean): string;
4
4
  export declare function applyLaunchBaseUrl(staging: boolean): void;
5
5
  export declare function fetchEnvironments(apiKey: string): Promise<Record<string, string>>;
6
6
  export declare function fetchChangeHistoryPage(apiKey: string, cursor?: string): Promise<LaunchChangeHistoryResponse>;
7
- export declare function fetchAllChangeHistory(apiKey: string, sinceEpochMs?: number): Promise<LaunchChangeEntry[]>;
7
+ export declare function fetchAllChangeHistory(apiKey: string, sinceEpochMs?: number, onProgress?: (fetched: number) => void): Promise<LaunchChangeEntry[]>;
@@ -49,7 +49,7 @@ export async function fetchChangeHistoryPage(apiKey, cursor) {
49
49
  }
50
50
  return (await apiFetch(`/api/v1/change-history?${params}`, apiKey));
51
51
  }
52
- export async function fetchAllChangeHistory(apiKey, sinceEpochMs) {
52
+ export async function fetchAllChangeHistory(apiKey, sinceEpochMs, onProgress) {
53
53
  const collected = [];
54
54
  const seenCursors = new Set();
55
55
  let cursor;
@@ -66,6 +66,9 @@ export async function fetchAllChangeHistory(apiKey, sinceEpochMs) {
66
66
  }
67
67
  collected.push(change);
68
68
  }
69
+ // Report cumulative progress after each page — pagination is silent and
70
+ // can run for many pages (50 changes/page) on a large account.
71
+ onProgress?.(collected.length);
69
72
  if (done || !page.cursor || seenCursors.has(page.cursor))
70
73
  break;
71
74
  seenCursors.add(page.cursor);
@@ -19,10 +19,10 @@ function requireApiKey(operation) {
19
19
  throw new MissingAuthError(operation);
20
20
  return state.apiKey;
21
21
  }
22
- async function* fetchChangesImpl(sinceEpochMs) {
22
+ async function* fetchChangesImpl(sinceEpochMs, onProgress) {
23
23
  const apiKey = requireApiKey('fetchChanges');
24
24
  const since = sinceEpochMs === null ? undefined : sinceEpochMs;
25
- const changes = await fetchAllChangeHistory(apiKey, since);
25
+ const changes = await fetchAllChangeHistory(apiKey, since, onProgress);
26
26
  for (const change of changes) {
27
27
  yield {
28
28
  changedAt: change.changedAt,
@@ -147,8 +147,8 @@ async function validateAuthImpl(apiKey) {
147
147
  state.coercedSentinels = new Map();
148
148
  }
149
149
  export const launchSource = {
150
- fetchChanges(sinceEpochMs) {
151
- return fetchChangesImpl(sinceEpochMs);
150
+ fetchChanges(sinceEpochMs, onProgress) {
151
+ return fetchChangesImpl(sinceEpochMs, onProgress);
152
152
  },
153
153
  getCoercedSentinels: getCoercedSentinelsImpl,
154
154
  getCommitMeta: getCommitMetaImpl,
@@ -27,6 +27,13 @@
27
27
  import { FileDelta } from './diff-summary.js';
28
28
  export interface BarePathDiffResult {
29
29
  deltas: FileDelta[];
30
+ /**
31
+ * The probe clone's HEAD sha — the workspace's current tip on the cloud
32
+ * repo. Threaded into `configs.push` as `expectedSha` so the server-side
33
+ * optimistic lock (qfg-gj3i) accepts bare-path pushes instead of rejecting
34
+ * them with a forced-upgrade 426.
35
+ */
36
+ remoteHeadSha: string;
30
37
  /** Tmp dir holding the probe clone. Caller must rm -rf in a finally block. */
31
38
  scratchDir: string;
32
39
  /** Total file count in the scratch clone (under the same prefix rules as `deltas`). */
@@ -52,6 +52,22 @@ export async function computeBarePathDiff(localDir, remoteUrl) {
52
52
  }
53
53
  throw error;
54
54
  }
55
+ // The probe clone's HEAD is the workspace tip — the optimistic-lock value
56
+ // `configs.push` needs as `expectedSha` (qfg-gj3i bare-path follow-up).
57
+ let remoteHeadSha;
58
+ try {
59
+ const { stdout } = await runGit(['-C', scratchDir, 'rev-parse', 'HEAD']);
60
+ remoteHeadSha = stdout.trim();
61
+ }
62
+ catch (error) {
63
+ try {
64
+ fs.rmSync(scratchDir, { force: true, recursive: true });
65
+ }
66
+ catch {
67
+ /* ignore */
68
+ }
69
+ throw error;
70
+ }
55
71
  const localFiles = collectFiles(localDir);
56
72
  const remoteFiles = collectFiles(scratchDir);
57
73
  const deltas = [];
@@ -69,7 +85,7 @@ export async function computeBarePathDiff(localDir, remoteUrl) {
69
85
  deltas.push({ kind: 'deleted', path: rel, beforeJson: remote.content });
70
86
  }
71
87
  }
72
- return { deltas, scratchDir, totalRemoteFiles: remoteFiles.size };
88
+ return { deltas, remoteHeadSha, scratchDir, totalRemoteFiles: remoteFiles.size };
73
89
  }
74
90
  /**
75
91
  * Walk `root` and return a map of relative-path -> {content, hash} for every
@@ -87,13 +87,11 @@ export interface GitOps {
87
87
  /** Pack-push — local HEAD SHA. Forwarded as `newSha` on the wire. */
88
88
  getHeadSha(dir: string): Promise<string>;
89
89
  /**
90
- * Returns the local SHA of `origin/main` after fetch — the remote tip
91
- * the diff was computed against — or undefined if no `origin/main` ref
92
- * exists (bare-path push, or repo never fetched).
93
- *
94
- * Threaded into the `configs.push` call as `expectedSha` so the
95
- * server-side optimistic lock (qfg-gj3i) can reject pushes whose
96
- * origin moved between fetch and push.
90
+ * Returns the workspace-HEAD sha that the diff was computed against —
91
+ * threaded into `configs.push` as `expectedSha` for the server-side
92
+ * optimistic lock (qfg-gj3i). Clone path: the local `origin/main` SHA
93
+ * after fetch. Bare path: the probe clone's HEAD. Undefined only when
94
+ * neither is available; the server rejects a missing value with a 426.
97
95
  */
98
96
  getOriginMainSha(dir: string): Promise<string | undefined>;
99
97
  /**
@@ -354,10 +354,10 @@ export async function runPush(input, deps) {
354
354
  ...(d.beforeJson === undefined ? {} : { beforeJson: d.beforeJson }),
355
355
  ...(d.afterJson === undefined ? {} : { afterJson: d.afterJson }),
356
356
  }));
357
- // qfg-gj3i: pass origin/main SHA as expectedSha so the server-side
358
- // optimistic lock can reject the push if origin moved between fetch
359
- // and now. Undefined on bare-path; the server treats absence as
360
- // "non-clone client" and applies its bare-path policy.
357
+ // qfg-gj3i: expectedSha is the workspace HEAD the server checks its
358
+ // optimistic lock against. For the bare path it is the probe clone's
359
+ // HEAD (resolved inside getOriginMainSha, qfg-nhcb). It must be present
360
+ // — the server rejects a missing expectedSha with a forced-upgrade 426.
361
361
  const expectedSha = await deps.gitOps.getOriginMainSha(input.dir);
362
362
  log('Sending push to Quonfig cloud...');
363
363
  const result = await deps.pushToServer({
package/dist/quonfig.js CHANGED
@@ -19,7 +19,6 @@ export const initQuonfig = async (_ctx, flagsOrDatafile) => {
19
19
  quonfig = new Quonfig({
20
20
  sdkKey,
21
21
  collectEvaluationSummaries: false,
22
- collectLoggerCounts: false,
23
22
  contextUploadMode: 'none',
24
23
  datafile,
25
24
  enableSSE: false,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Datadir discovery for `qfg serve`.
3
+ *
4
+ * Resolution order (plan §4):
5
+ * 1. --datadir flag
6
+ * 2. QUONFIG_DIR env var
7
+ * 3. ./our-config if present
8
+ * 4. ./.quonfig if present
9
+ * 5. error
10
+ *
11
+ * Kept narrow on purpose: `qfg pull`/`qfg push` use a richer
12
+ * `resolveWorkspaceDir` helper that walks up looking for `quonfig.json` — but
13
+ * `qfg serve` is intentionally cwd-relative (the user said "serve THIS dir")
14
+ * and the walk would surface a confusing path on a misconfigured repo. The
15
+ * default-pair lookup mirrors `qfg pull --dir` defaults documented in the
16
+ * plan.
17
+ */
18
+ export declare const DEFAULT_DATADIR_CANDIDATES: readonly ["our-config", ".quonfig"];
19
+ export declare const NO_DATADIR_ERROR: string;
20
+ export interface ResolveDatadirInput {
21
+ cwd: string;
22
+ envDatadir: string | undefined;
23
+ flagDatadir: string | undefined;
24
+ }
25
+ export type ResolveDatadirResult = {
26
+ kind: 'ok';
27
+ dir: string;
28
+ source: 'flag' | 'env' | 'cwd-default';
29
+ } | {
30
+ kind: 'error';
31
+ message: string;
32
+ };
33
+ export declare const resolveDatadirForServe: (input: ResolveDatadirInput) => ResolveDatadirResult;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Datadir discovery for `qfg serve`.
3
+ *
4
+ * Resolution order (plan §4):
5
+ * 1. --datadir flag
6
+ * 2. QUONFIG_DIR env var
7
+ * 3. ./our-config if present
8
+ * 4. ./.quonfig if present
9
+ * 5. error
10
+ *
11
+ * Kept narrow on purpose: `qfg pull`/`qfg push` use a richer
12
+ * `resolveWorkspaceDir` helper that walks up looking for `quonfig.json` — but
13
+ * `qfg serve` is intentionally cwd-relative (the user said "serve THIS dir")
14
+ * and the walk would surface a confusing path on a misconfigured repo. The
15
+ * default-pair lookup mirrors `qfg pull --dir` defaults documented in the
16
+ * plan.
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ export const DEFAULT_DATADIR_CANDIDATES = ['our-config', '.quonfig'];
21
+ export const NO_DATADIR_ERROR = 'No datadir found. Pass --datadir <path>, set QUONFIG_DIR, ' +
22
+ `or run from a directory containing ${DEFAULT_DATADIR_CANDIDATES.map((c) => `./${c}`).join(' or ')}.`;
23
+ const blank = (s) => s === undefined || s === '';
24
+ export const resolveDatadirForServe = (input) => {
25
+ if (!blank(input.flagDatadir)) {
26
+ return { kind: 'ok', dir: path.resolve(input.cwd, input.flagDatadir), source: 'flag' };
27
+ }
28
+ if (!blank(input.envDatadir)) {
29
+ return { kind: 'ok', dir: path.resolve(input.cwd, input.envDatadir), source: 'env' };
30
+ }
31
+ for (const candidate of DEFAULT_DATADIR_CANDIDATES) {
32
+ const abs = path.resolve(input.cwd, candidate);
33
+ if (fs.existsSync(abs)) {
34
+ return { kind: 'ok', dir: abs, source: 'cwd-default' };
35
+ }
36
+ }
37
+ return { kind: 'error', message: NO_DATADIR_ERROR };
38
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Handler for `GET /api/v2/configs/eval-with-context/{base64url(ctx)}`.
3
+ *
4
+ * Mirrors api-delivery/internal/serve/eval_context.go so SDK clients see the
5
+ * same EvalEnvelope shape they get in production. The fields are:
6
+ *
7
+ * { evaluations: { [key]: {
8
+ * value: { type, value, confidential?, decryptWith? },
9
+ * configId, configType, valueType, reason,
10
+ * ruleIndex?, weightedValueIndex?
11
+ * } },
12
+ * meta: { version, environment }
13
+ * }
14
+ *
15
+ * `reason` follows the cross-SDK spec
16
+ * `project/plans/openfeature-resolution-details.md`:
17
+ * - "SPLIT" for weighted-value flags
18
+ * - "TARGETING_MATCH" for configs whose matched rule has a non-ALWAYS_TRUE criterion
19
+ * - "STATIC" otherwise
20
+ *
21
+ * `ruleIndex` and `weightedValueIndex` are emitted only when the reason class
22
+ * carries useful information (TARGETING_MATCH or SPLIT). This matches the Go
23
+ * handler's "omit, don't null" rule.
24
+ */
25
+ import type { IncomingMessage, ServerResponse } from 'node:http';
26
+ import type { Evaluator, ConfigStore } from '@quonfig/node';
27
+ export interface EvalEnvelope {
28
+ evaluations: Record<string, EvalResult>;
29
+ meta: {
30
+ version: string;
31
+ environment: string;
32
+ };
33
+ }
34
+ export interface EvalResult {
35
+ configId: string;
36
+ configType: string;
37
+ reason: string;
38
+ ruleIndex?: number;
39
+ value: {
40
+ type: string;
41
+ value: unknown;
42
+ confidential?: boolean;
43
+ decryptWith?: string;
44
+ };
45
+ valueType: string;
46
+ weightedValueIndex?: number;
47
+ }
48
+ export interface EvalHandlerDeps {
49
+ /**
50
+ * Snapshot accessor — returns the *current* store + evaluator + meta. We
51
+ * read this on every request so that auto-reload swaps under us
52
+ * transparently.
53
+ *
54
+ * Note (qfg-38sf.1 audit, F3): the sendToClientSdk filter is unconditional
55
+ * and lives inside this handler. There is no `frontendFilter` toggle —
56
+ * every qfg serve consumer is a frontend client by definition, so the
57
+ * filter always applies.
58
+ */
59
+ getSnapshot(): {
60
+ store: ConfigStore;
61
+ evaluator: Evaluator;
62
+ environment: string;
63
+ version: string;
64
+ };
65
+ }
66
+ export declare function handleEvalContext(req: IncomingMessage, res: ServerResponse, ctxToken: string, deps: EvalHandlerDeps): Promise<void>;