@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.
- package/README.md +102 -44
- package/dist/commands/migrate.d.ts +6 -0
- package/dist/commands/migrate.js +69 -7
- package/dist/commands/push.js +9 -3
- package/dist/commands/run.js +0 -1
- package/dist/commands/serve.d.ts +23 -0
- package/dist/commands/serve.js +136 -0
- package/dist/commands/workspace/create.js +9 -1
- package/dist/migrate/local-write.d.ts +1 -1
- package/dist/migrate/local-write.js +1 -1
- package/dist/migrate/migration-report.d.ts +11 -0
- package/dist/migrate/migration-report.js +13 -2
- package/dist/migrate/push-to-cloud.d.ts +2 -2
- package/dist/migrate/source.d.ts +7 -1
- package/dist/migrate/sources/launch/api.d.ts +1 -1
- package/dist/migrate/sources/launch/api.js +4 -1
- package/dist/migrate/sources/launch.js +4 -4
- package/dist/push/bare-path-diff.d.ts +7 -0
- package/dist/push/bare-path-diff.js +17 -1
- package/dist/push/run-push.d.ts +5 -7
- package/dist/push/run-push.js +4 -4
- 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 +141 -0
- package/dist/serve/server.d.ts +53 -0
- package/dist/serve/server.js +355 -0
- package/dist/util/friction-log.d.ts +7 -1
- package/dist/util/friction-log.js +9 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +271 -156
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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` +
|
|
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` +
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/migrate/source.d.ts
CHANGED
|
@@ -87,7 +87,13 @@ export interface CommitMeta {
|
|
|
87
87
|
message: string;
|
|
88
88
|
}
|
|
89
89
|
export interface MigrationSource {
|
|
90
|
-
|
|
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
|
package/dist/push/run-push.d.ts
CHANGED
|
@@ -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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
/**
|
package/dist/push/run-push.js
CHANGED
|
@@ -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:
|
|
358
|
-
// optimistic lock
|
|
359
|
-
//
|
|
360
|
-
//
|
|
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
|
@@ -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>;
|