@quonfig/cli 0.0.71 → 0.0.73
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 +97 -73
- package/dist/commands/cleanup/status.js +5 -1
- package/dist/commands/config-schema.js +8 -3
- package/dist/commands/get.js +6 -4
- package/dist/commands/info.js +9 -15
- package/dist/commands/log-level.js +7 -10
- package/dist/commands/set-default.d.ts +1 -0
- package/dist/commands/set-default.js +106 -71
- package/dist/commands/set-rollout.d.ts +1 -0
- package/dist/commands/set-rollout.js +65 -18
- package/dist/commands/workspace.d.ts +10 -1
- package/dist/commands/workspace.js +72 -13
- package/dist/index.d.ts +30 -0
- package/dist/index.js +105 -8
- package/dist/init/templates.js +43 -0
- package/dist/push/run-push.d.ts +21 -0
- package/dist/push/run-push.js +26 -3
- package/dist/util/rules.d.ts +98 -0
- package/dist/util/rules.js +131 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +18 -5
- package/package.json +1 -1
|
@@ -21,7 +21,31 @@ export default class Workspace extends BaseCommand {
|
|
|
21
21
|
// /userWorkspaces/list is per-token-org-scoped — a single org's JWT
|
|
22
22
|
// only sees that org's workspaces. Iterate every cached org so the
|
|
23
23
|
// listing is comprehensive in multi-org accounts.
|
|
24
|
-
const allWorkspaces = await this.fetchAllOrgsWorkspaces(orgEntries);
|
|
24
|
+
const { failures, workspaces: allWorkspaces } = await this.fetchAllOrgsWorkspaces(orgEntries);
|
|
25
|
+
// qfg-t15h: an empty listing used to be indistinguishable from "your
|
|
26
|
+
// session is dead", so an expired login rendered as "(no workspaces
|
|
27
|
+
// yet)" and nudged the user toward creating a duplicate workspace.
|
|
28
|
+
// When NOTHING could be listed and the cause is auth, fail the way
|
|
29
|
+
// `qfg pull`/`qfg get` do (get-client.ts:141) instead of printing a
|
|
30
|
+
// listing we know is a lie.
|
|
31
|
+
const authFailures = failures.filter((f) => f.auth);
|
|
32
|
+
if (failures.length === orgEntries.length && authFailures.length > 0) {
|
|
33
|
+
const label = authFailures.length === 1
|
|
34
|
+
? `org ${authFailures[0].orgSlug}`
|
|
35
|
+
: `orgs ${authFailures.map((f) => f.orgSlug).join(', ')}`;
|
|
36
|
+
const detail = authFailures.length === 1
|
|
37
|
+
? authFailures[0].detail
|
|
38
|
+
: authFailures.map((f) => `${f.orgSlug}: ${f.detail}`).join('; ');
|
|
39
|
+
this.error(`Session expired for ${label}: ${detail}. Run \`qfg login\` to re-authenticate.`, { exit: 401 });
|
|
40
|
+
}
|
|
41
|
+
// Partial failure keeps the deliberate multi-org isolation above: the
|
|
42
|
+
// healthy orgs still list, the dead ones say so instead of looking empty.
|
|
43
|
+
for (const f of failures) {
|
|
44
|
+
this.logToStderr(f.auth
|
|
45
|
+
? `Warning: could not list workspaces for org ${f.orgSlug} — ${f.detail}. Run \`qfg login\` to re-authenticate.`
|
|
46
|
+
: `Warning: could not list workspaces for org ${f.orgSlug} — ${f.detail}.`);
|
|
47
|
+
}
|
|
48
|
+
const failedOrgIds = new Set(failures.map((f) => f.orgId));
|
|
25
49
|
const defaultProfilePin = this.profilePin(authConfig, allWorkspaces);
|
|
26
50
|
const activePin = this.activePin(store, authConfig, allWorkspaces, defaultProfilePin);
|
|
27
51
|
this.log(`Active workspace: ${activePin ?? '(none)'}`);
|
|
@@ -37,7 +61,10 @@ export default class Workspace extends BaseCommand {
|
|
|
37
61
|
for (const group of grouped) {
|
|
38
62
|
this.log(` ${group.orgSlug}:`);
|
|
39
63
|
if (group.workspaces.length === 0) {
|
|
40
|
-
|
|
64
|
+
// qfg-t15h: "yet" is only true if we actually got an answer.
|
|
65
|
+
this.log(failedOrgIds.has(group.workosOrgId)
|
|
66
|
+
? ' (could not list workspaces — see warning above)'
|
|
67
|
+
: ' (no workspaces yet — `qfg workspace create` to add one)');
|
|
41
68
|
continue;
|
|
42
69
|
}
|
|
43
70
|
for (const w of group.workspaces) {
|
|
@@ -56,6 +83,10 @@ export default class Workspace extends BaseCommand {
|
|
|
56
83
|
activeWorkspace: activePin,
|
|
57
84
|
defaultProfile: defaultProfilePin,
|
|
58
85
|
orgsWithTokens: orgSlugsWithTokens,
|
|
86
|
+
// qfg-t15h: --json suppresses the stderr warnings, so the failures
|
|
87
|
+
// have to be in the payload or a script cannot tell an org that
|
|
88
|
+
// returned nothing from one that could not be asked.
|
|
89
|
+
failedOrgs: failures.map((f) => ({ orgSlug: f.orgSlug, authFailure: f.auth, reason: f.detail })),
|
|
59
90
|
orgs: grouped.map((g) => ({
|
|
60
91
|
orgSlug: g.orgSlug,
|
|
61
92
|
workosOrgId: g.workosOrgId,
|
|
@@ -90,28 +121,47 @@ export default class Workspace extends BaseCommand {
|
|
|
90
121
|
}
|
|
91
122
|
/**
|
|
92
123
|
* Fan out across every cached org's token and merge the results. One org's
|
|
93
|
-
* stale token doesn't block the others —
|
|
124
|
+
* stale token doesn't block the others — that isolation is deliberate for
|
|
125
|
+
* multi-org accounts. What is NOT ok (qfg-t15h) is losing the fact that an
|
|
126
|
+
* org failed: each failure is returned so the caller can decide between a
|
|
127
|
+
* hard session-expired error (nothing listed) and a per-org warning
|
|
128
|
+
* alongside the orgs that did answer.
|
|
94
129
|
*/
|
|
95
130
|
async fetchAllOrgsWorkspaces(orgEntries) {
|
|
96
131
|
const merged = new Map();
|
|
97
|
-
|
|
132
|
+
const failures = [];
|
|
133
|
+
for (const [orgId, tokens] of orgEntries) {
|
|
134
|
+
const orgSlug = tokens.org_slug ?? orgId;
|
|
98
135
|
let jwt;
|
|
99
136
|
try {
|
|
100
137
|
// eslint-disable-next-line no-await-in-loop
|
|
101
138
|
jwt = await getValidAccessToken(orgId, this.verboseLog);
|
|
102
139
|
}
|
|
103
140
|
catch (error) {
|
|
104
|
-
|
|
141
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
142
|
+
this.verboseLog('workspace: token refresh failed for org', { orgId, error: detail });
|
|
143
|
+
// A refresh that fails is always an auth failure — the stored
|
|
144
|
+
// grant is dead (invalid_grant) or there is no refresh token.
|
|
145
|
+
failures.push({ auth: true, detail, orgId, orgSlug });
|
|
105
146
|
continue;
|
|
106
147
|
}
|
|
107
148
|
// eslint-disable-next-line no-await-in-loop
|
|
108
|
-
const
|
|
109
|
-
|
|
149
|
+
const listed = await this.fetchWorkspaces(jwt);
|
|
150
|
+
if (listed.kind === 'failed') {
|
|
151
|
+
failures.push({ auth: listed.auth, detail: listed.detail, orgId, orgSlug });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
for (const w of listed.entries) {
|
|
110
155
|
merged.set(w.workspaceId, w);
|
|
111
156
|
}
|
|
112
157
|
}
|
|
113
|
-
return [...merged.values()];
|
|
158
|
+
return { failures, workspaces: [...merged.values()] };
|
|
114
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* qfg-t15h: returns a tagged result rather than a bare array, so a 401 or a
|
|
162
|
+
* dead connection is distinguishable from an org that genuinely has no
|
|
163
|
+
* workspaces yet.
|
|
164
|
+
*/
|
|
115
165
|
async fetchWorkspaces(jwt) {
|
|
116
166
|
try {
|
|
117
167
|
const res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
|
|
@@ -121,15 +171,20 @@ export default class Workspace extends BaseCommand {
|
|
|
121
171
|
});
|
|
122
172
|
if (!res.ok) {
|
|
123
173
|
this.verboseLog('workspace: userWorkspaces/list non-OK', { status: res.status });
|
|
124
|
-
return
|
|
174
|
+
return {
|
|
175
|
+
auth: res.status === 401 || res.status === 403,
|
|
176
|
+
detail: `workspace listing failed (HTTP ${res.status})`,
|
|
177
|
+
kind: 'failed',
|
|
178
|
+
};
|
|
125
179
|
}
|
|
126
180
|
const body = (await res.json());
|
|
127
181
|
const list = (body.json ?? body);
|
|
128
|
-
return Array.isArray(list) ? list : [];
|
|
182
|
+
return { entries: Array.isArray(list) ? list : [], kind: 'ok' };
|
|
129
183
|
}
|
|
130
184
|
catch (error) {
|
|
131
|
-
|
|
132
|
-
|
|
185
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
186
|
+
this.verboseLog('workspace: userWorkspaces/list failed', { error: detail });
|
|
187
|
+
return { auth: false, detail, kind: 'failed' };
|
|
133
188
|
}
|
|
134
189
|
}
|
|
135
190
|
/**
|
|
@@ -164,7 +219,11 @@ export default class Workspace extends BaseCommand {
|
|
|
164
219
|
catch (error) {
|
|
165
220
|
return this.err(error instanceof Error ? error.message : String(error));
|
|
166
221
|
}
|
|
167
|
-
const
|
|
222
|
+
const listed = await this.fetchWorkspaces(jwt);
|
|
223
|
+
if (listed.kind === 'failed') {
|
|
224
|
+
return this.err(`Failed to resolve workspace "${override}" — ${listed.detail}.`);
|
|
225
|
+
}
|
|
226
|
+
const { entries } = listed;
|
|
168
227
|
if (entries.length === 0) {
|
|
169
228
|
return this.err(`Failed to resolve workspace "${override}" — no entries returned.`);
|
|
170
229
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,27 @@ import { Command } from '@oclif/core';
|
|
|
3
3
|
import { Client } from '@quonfig/node';
|
|
4
4
|
import type { ProjectEnvId } from '@quonfig/node';
|
|
5
5
|
import { JsonObj, Result } from './result.js';
|
|
6
|
+
/**
|
|
7
|
+
* The exit code oclif actually recorded on a thrown error.
|
|
8
|
+
*
|
|
9
|
+
* `Errors.CLIError` — what `this.error(msg, {exit: N})` throws — stores N on
|
|
10
|
+
* `oclif.exit` and has NO `exitCode` property at all. Reading `err.exitCode`
|
|
11
|
+
* therefore collapsed every non-default code (401 on the auth failures in
|
|
12
|
+
* `resolve-workspace.ts` / `get-client.ts`, for one) down to 1. Exported so the
|
|
13
|
+
* behaviour is unit-testable. See qfg-hzmb.
|
|
14
|
+
*/
|
|
15
|
+
export declare function errorExitCode(err: unknown): number;
|
|
16
|
+
/**
|
|
17
|
+
* The code to actually terminate the process with.
|
|
18
|
+
*
|
|
19
|
+
* A POSIX exit status is one byte. Several call sites pass an HTTP-flavoured
|
|
20
|
+
* code — `{exit: 401}` for the auth failures in `get-client.ts` and
|
|
21
|
+
* `resolve-workspace.ts` — and `process.exit(401)` truncates to 145, which a
|
|
22
|
+
* shell reads as "killed by signal 17". Anything outside 1-255 therefore
|
|
23
|
+
* terminates with the plain failure code; the caller's real intent is still
|
|
24
|
+
* reported verbatim as `error.exitCode` in the `--json` envelope.
|
|
25
|
+
*/
|
|
26
|
+
export declare function processExitCode(err: unknown): number;
|
|
6
27
|
export declare abstract class BaseCommand extends Command {
|
|
7
28
|
static baseFlags: {
|
|
8
29
|
interactive: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
@@ -20,6 +41,15 @@ export declare abstract class BaseCommand extends Command {
|
|
|
20
41
|
code?: string;
|
|
21
42
|
} & Error): Promise<void>;
|
|
22
43
|
init(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* oclif's default is `{error: err}`, but `message`, `code` and `stack` are
|
|
46
|
+
* non-enumerable on an Error, so `JSON.stringify` flattens a thrown Error all
|
|
47
|
+
* the way down to `{}`. Re-attach the useful fields explicitly and keep the
|
|
48
|
+
* caller's payload alongside them. See qfg-hzmb.
|
|
49
|
+
*/
|
|
50
|
+
protected toErrorJson(err: unknown): {
|
|
51
|
+
error: JsonObj;
|
|
52
|
+
};
|
|
23
53
|
}
|
|
24
54
|
export declare abstract class APICommand extends BaseCommand {
|
|
25
55
|
static baseFlags: {
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { run } from '@oclif/core';
|
|
2
|
-
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { Command, Errors, Flags } from '@oclif/core';
|
|
3
3
|
import rawGetClient, { unwrapRequest } from './util/get-client.js';
|
|
4
4
|
const globalFlags = {
|
|
5
5
|
interactive: Flags.boolean({
|
|
@@ -20,19 +20,71 @@ const globalFlags = {
|
|
|
20
20
|
helpGroup: 'GLOBAL',
|
|
21
21
|
}),
|
|
22
22
|
};
|
|
23
|
+
const DEFAULT_EXIT_CODE = 1;
|
|
24
|
+
/**
|
|
25
|
+
* The exit code oclif actually recorded on a thrown error.
|
|
26
|
+
*
|
|
27
|
+
* `Errors.CLIError` — what `this.error(msg, {exit: N})` throws — stores N on
|
|
28
|
+
* `oclif.exit` and has NO `exitCode` property at all. Reading `err.exitCode`
|
|
29
|
+
* therefore collapsed every non-default code (401 on the auth failures in
|
|
30
|
+
* `resolve-workspace.ts` / `get-client.ts`, for one) down to 1. Exported so the
|
|
31
|
+
* behaviour is unit-testable. See qfg-hzmb.
|
|
32
|
+
*/
|
|
33
|
+
export function errorExitCode(err) {
|
|
34
|
+
const candidate = err;
|
|
35
|
+
if (typeof candidate?.oclif?.exit === 'number')
|
|
36
|
+
return candidate.oclif.exit;
|
|
37
|
+
if (typeof candidate?.exitCode === 'number')
|
|
38
|
+
return candidate.exitCode;
|
|
39
|
+
return DEFAULT_EXIT_CODE;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The code to actually terminate the process with.
|
|
43
|
+
*
|
|
44
|
+
* A POSIX exit status is one byte. Several call sites pass an HTTP-flavoured
|
|
45
|
+
* code — `{exit: 401}` for the auth failures in `get-client.ts` and
|
|
46
|
+
* `resolve-workspace.ts` — and `process.exit(401)` truncates to 145, which a
|
|
47
|
+
* shell reads as "killed by signal 17". Anything outside 1-255 therefore
|
|
48
|
+
* terminates with the plain failure code; the caller's real intent is still
|
|
49
|
+
* reported verbatim as `error.exitCode` in the `--json` envelope.
|
|
50
|
+
*/
|
|
51
|
+
export function processExitCode(err) {
|
|
52
|
+
const intended = errorExitCode(err);
|
|
53
|
+
return Number.isInteger(intended) && intended >= 1 && intended <= 255 ? intended : DEFAULT_EXIT_CODE;
|
|
54
|
+
}
|
|
55
|
+
/** Best available human-readable message for anything that can be thrown or passed to `err()`. */
|
|
56
|
+
function errorMessage(error) {
|
|
57
|
+
if (typeof error === 'string')
|
|
58
|
+
return error;
|
|
59
|
+
if (error instanceof Error)
|
|
60
|
+
return error.message;
|
|
61
|
+
if (typeof error !== 'object' || error === null)
|
|
62
|
+
return String(error);
|
|
63
|
+
const record = error;
|
|
64
|
+
if (typeof record.message === 'string')
|
|
65
|
+
return record.message;
|
|
66
|
+
if (typeof record.error === 'string')
|
|
67
|
+
return record.error;
|
|
68
|
+
return JSON.stringify(error);
|
|
69
|
+
}
|
|
23
70
|
export class BaseCommand extends Command {
|
|
24
71
|
static baseFlags = {
|
|
25
72
|
...globalFlags,
|
|
26
73
|
};
|
|
27
74
|
static enableJsonFlag = true;
|
|
28
75
|
err = (error, json) => {
|
|
76
|
+
const message = errorMessage(error);
|
|
29
77
|
if (this.jsonEnabled()) {
|
|
30
|
-
throw
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
78
|
+
// qfg-hzmb: throw a real CLIError instead of a bare object, so the message
|
|
79
|
+
// and the exit code survive to catch() and reach stdout. The caller's
|
|
80
|
+
// structured payload is copied onto it, keeping the older contract that
|
|
81
|
+
// callers (and tests) read fields such as `missingEnvVar` straight off the
|
|
82
|
+
// thrown value.
|
|
83
|
+
const cliError = new Errors.CLIError(message, { code: 'ERR', exit: DEFAULT_EXIT_CODE });
|
|
84
|
+
Object.assign(cliError, json ?? (typeof error === 'object' && error !== null ? error : {}));
|
|
85
|
+
throw cliError;
|
|
34
86
|
}
|
|
35
|
-
this.error(
|
|
87
|
+
return this.error(message, { code: 'ERR', exit: DEFAULT_EXIT_CODE });
|
|
36
88
|
};
|
|
37
89
|
isVerbose;
|
|
38
90
|
ok = (message, json) => {
|
|
@@ -67,19 +119,64 @@ export class BaseCommand extends Command {
|
|
|
67
119
|
async catch(err) {
|
|
68
120
|
// Override oclif's default error handling to suppress stack traces in production
|
|
69
121
|
// but preserve error messages for tests
|
|
122
|
+
// `this.exit(n)` throws an ExitError: a control-flow signal, not a failure to
|
|
123
|
+
// report. `qfg verify --json` and `qfg migrate doctor --json` deliberately
|
|
124
|
+
// print their payload and *then* exit non-zero (qfg-ez47), so reporting the
|
|
125
|
+
// ExitError here would append a second JSON document to stdout. Hand it
|
|
126
|
+
// straight to oclif, which prints nothing and honours `oclif.exit`.
|
|
127
|
+
if (err instanceof Errors.ExitError || err.code === 'EEXIT') {
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
// qfg-hzmb: `this.log()` is a documented no-op whenever jsonEnabled(), so
|
|
131
|
+
// this override used to print NOTHING for a failing `--json` run — empty
|
|
132
|
+
// stdout was indistinguishable from an empty success. Emit the structured
|
|
133
|
+
// envelope on stdout, exactly as oclif's own catch() does.
|
|
134
|
+
if (this.jsonEnabled()) {
|
|
135
|
+
this.logJson(this.toErrorJson(err));
|
|
136
|
+
}
|
|
70
137
|
// In test environment, just re-throw the error
|
|
71
138
|
if (process.env.NODE_ENV === 'test') {
|
|
72
139
|
throw err;
|
|
73
140
|
}
|
|
74
141
|
// In production, log the error message without stack trace
|
|
75
|
-
this.
|
|
76
|
-
|
|
142
|
+
if (!this.jsonEnabled()) {
|
|
143
|
+
this.log(err.message);
|
|
144
|
+
}
|
|
145
|
+
this.exit(processExitCode(err));
|
|
77
146
|
}
|
|
78
147
|
async init() {
|
|
79
148
|
await super.init();
|
|
80
149
|
const { flags } = await this.parse();
|
|
81
150
|
this.isVerbose = flags.verbose;
|
|
82
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* oclif's default is `{error: err}`, but `message`, `code` and `stack` are
|
|
154
|
+
* non-enumerable on an Error, so `JSON.stringify` flattens a thrown Error all
|
|
155
|
+
* the way down to `{}`. Re-attach the useful fields explicitly and keep the
|
|
156
|
+
* caller's payload alongside them. See qfg-hzmb.
|
|
157
|
+
*/
|
|
158
|
+
toErrorJson(err) {
|
|
159
|
+
if (typeof err !== 'object' || err === null) {
|
|
160
|
+
return { error: { code: 'ERR', exitCode: errorExitCode(err), message: errorMessage(err) } };
|
|
161
|
+
}
|
|
162
|
+
// An Error's own *enumerable* properties are the structured payload handed
|
|
163
|
+
// to `err()` — keep those. `oclif` is internal bookkeeping (surfaced as
|
|
164
|
+
// `exitCode`), and `stack` is dropped on purpose (oclif.stacktrace=false).
|
|
165
|
+
const payload = {};
|
|
166
|
+
for (const [key, value] of Object.entries(err)) {
|
|
167
|
+
if (key !== 'oclif' && key !== 'stack')
|
|
168
|
+
payload[key] = value;
|
|
169
|
+
}
|
|
170
|
+
const { code } = err;
|
|
171
|
+
return {
|
|
172
|
+
error: {
|
|
173
|
+
...payload,
|
|
174
|
+
code: typeof code === 'string' ? code : 'ERR',
|
|
175
|
+
exitCode: errorExitCode(err),
|
|
176
|
+
message: errorMessage(err),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
83
180
|
}
|
|
84
181
|
export class APICommand extends BaseCommand {
|
|
85
182
|
static baseFlags = {
|
package/dist/init/templates.js
CHANGED
|
@@ -195,6 +195,49 @@ This is a Quonfig workspace repository. See \`CLAUDE.md\` for the complete schem
|
|
|
195
195
|
4. Filename must match the \`key\` field (e.g. \`my.flag.json\` has \`"key": "my.flag"\`).
|
|
196
196
|
5. Files must be in the correct directory for their \`type\`.
|
|
197
197
|
6. See \`CLAUDE.md\` for the full list of value types, operators, and constraints.
|
|
198
|
+
|
|
199
|
+
## \`qfg\` or the Quonfig MCP?
|
|
200
|
+
|
|
201
|
+
Only relevant if this workspace is *also* hosted on a Quonfig account. On the
|
|
202
|
+
fully-local path, editing the JSON in this repo is the whole story.
|
|
203
|
+
|
|
204
|
+
- **This repo is checked out and you have a shell** -> use \`qfg\`. The files are
|
|
205
|
+
already on disk, so reading them costs nothing.
|
|
206
|
+
- **Slack, claude.ai, or anywhere without a checkout** -> use the Quonfig MCP
|
|
207
|
+
server. It reads and writes the hosted workspace directly. Do not try to shell
|
|
208
|
+
out to \`qfg\` from a surface that has no repo.
|
|
209
|
+
|
|
210
|
+
Both surfaces cover the same everyday loop — create a flag or config, change what
|
|
211
|
+
an environment serves, set a log level. (\`qfg\` has all of it today; the MCP's
|
|
212
|
+
create/config/log-level verbs land with its v2 write surface.)
|
|
213
|
+
|
|
214
|
+
## Setting a value KEEPS that environment's targeting rules
|
|
215
|
+
|
|
216
|
+
Both surfaces write the same way, so learn it once:
|
|
217
|
+
|
|
218
|
+
\`qfg set-default\` (alias \`toggle\`), \`qfg set-rollout\`, and the MCP
|
|
219
|
+
\`set_flag\`/\`set_config\` verbs replace exactly ONE rule — the environment's
|
|
220
|
+
**fallback**, the unconditional rule at the end of its rule list that decides
|
|
221
|
+
what users receive when no targeting rule matches. Every targeting rule above
|
|
222
|
+
it is kept, and both surfaces report how many. An environment with no rules of
|
|
223
|
+
its own has them copied from default first, so inherited targeting is kept too.
|
|
224
|
+
|
|
225
|
+
| Surface | By default | To set the value for EVERYONE |
|
|
226
|
+
|---------|------------|-------------------------------|
|
|
227
|
+
| \`qfg set-default\` / \`qfg set-rollout\` | Keeps targeting; reports "Kept N targeting rule(s)" | add \`--replace-targeting\` |
|
|
228
|
+
| MCP \`set_flag\` / \`set_config\` | Keeps targeting; reports the kept count in its result | send \`replaceTargeting: true\` |
|
|
229
|
+
|
|
230
|
+
Setting the value for everyone DELETES that environment's targeting rules. They
|
|
231
|
+
stay in git history, and the write returns a \`previousCommitSha\` to restore from.
|
|
232
|
+
|
|
233
|
+
**Still read before you write.** \`qfg info <key>\` (or MCP \`get_flag\`) shows the
|
|
234
|
+
rules your value is about to sit next to. For anything a fallback cannot express —
|
|
235
|
+
multi-rule targeting, reordering, editing one targeting rule — edit the JSON
|
|
236
|
+
directly or use MCP \`set_document\`.
|
|
237
|
+
|
|
238
|
+
\`qfg log-level --target ...\` and MCP \`set_log_level\` are **surgical** in the same
|
|
239
|
+
way: they upsert the level for the loggers you name and leave every other rule in
|
|
240
|
+
place.
|
|
198
241
|
`;
|
|
199
242
|
}
|
|
200
243
|
// ── Pre-commit hook ────────────────────────────────────────────────────
|
package/dist/push/run-push.d.ts
CHANGED
|
@@ -319,4 +319,25 @@ export declare class PushFatalError extends Error {
|
|
|
319
319
|
* caller has been updated to drop the call entirely.
|
|
320
320
|
*/
|
|
321
321
|
export declare const withPushedViaTrailer: (message: string) => string;
|
|
322
|
+
/**
|
|
323
|
+
* Render the detail half of one push-denial line (qfg-szte).
|
|
324
|
+
*
|
|
325
|
+
* The server's `reason` is already a complete sentence: every family in
|
|
326
|
+
* app-quonfig `authorize-push-files.ts` renders
|
|
327
|
+
* `Missing required permission to edit <path>: …<requiredPermission>`, so it
|
|
328
|
+
* names both the path and the slug. The clone path used to append
|
|
329
|
+
* `, requires <requiredPermission>` on top of that (slug and path twice), and
|
|
330
|
+
* the bare path used to print the slug alone and throw the reason away — so
|
|
331
|
+
* the "why" (which environment is protected, which rule reaches the default)
|
|
332
|
+
* never reached the user.
|
|
333
|
+
*
|
|
334
|
+
* Both now render the reason, and add back only the field it does not
|
|
335
|
+
* already carry. That keeps the line honest against a server that sends a
|
|
336
|
+
* terser reason than today's without ever duplicating anything.
|
|
337
|
+
*/
|
|
338
|
+
export declare function formatDenialDetail(d: {
|
|
339
|
+
path: string;
|
|
340
|
+
reason: string;
|
|
341
|
+
requiredPermission: string;
|
|
342
|
+
}): string;
|
|
322
343
|
export declare function runPush(input: RunPushInput, deps: RunPushDeps): Promise<RunPushResult>;
|
package/dist/push/run-push.js
CHANGED
|
@@ -64,6 +64,27 @@ const sameRepo = (a, b) => normalizeForCompare(a) === normalizeForCompare(b);
|
|
|
64
64
|
* caller has been updated to drop the call entirely.
|
|
65
65
|
*/
|
|
66
66
|
export const withPushedViaTrailer = (message) => message;
|
|
67
|
+
/**
|
|
68
|
+
* Render the detail half of one push-denial line (qfg-szte).
|
|
69
|
+
*
|
|
70
|
+
* The server's `reason` is already a complete sentence: every family in
|
|
71
|
+
* app-quonfig `authorize-push-files.ts` renders
|
|
72
|
+
* `Missing required permission to edit <path>: …<requiredPermission>`, so it
|
|
73
|
+
* names both the path and the slug. The clone path used to append
|
|
74
|
+
* `, requires <requiredPermission>` on top of that (slug and path twice), and
|
|
75
|
+
* the bare path used to print the slug alone and throw the reason away — so
|
|
76
|
+
* the "why" (which environment is protected, which rule reaches the default)
|
|
77
|
+
* never reached the user.
|
|
78
|
+
*
|
|
79
|
+
* Both now render the reason, and add back only the field it does not
|
|
80
|
+
* already carry. That keeps the line honest against a server that sends a
|
|
81
|
+
* terser reason than today's without ever duplicating anything.
|
|
82
|
+
*/
|
|
83
|
+
export function formatDenialDetail(d) {
|
|
84
|
+
const reason = d.reason.trim() || `missing permission ${d.requiredPermission}`;
|
|
85
|
+
const withPath = reason.includes(d.path) ? reason : `${d.path}: ${reason}`;
|
|
86
|
+
return withPath.includes(d.requiredPermission) ? withPath : `${withPath} (requires ${d.requiredPermission})`;
|
|
87
|
+
}
|
|
67
88
|
export async function runPush(input, deps) {
|
|
68
89
|
const log = deps.log ?? ((s) => console.log(s));
|
|
69
90
|
const errLog = deps.errLog ?? ((s) => console.error(s));
|
|
@@ -272,8 +293,8 @@ export async function runPush(input, deps) {
|
|
|
272
293
|
if (packResult.kind === 'denied') {
|
|
273
294
|
errLog(`Push denied for ${packResult.denials.length} commit(s):`);
|
|
274
295
|
for (const d of packResult.denials) {
|
|
275
|
-
// qfg-7429.5 denial line: `<short-sha> <
|
|
276
|
-
errLog(` ${d.commitSha.slice(0, 8)} ${d
|
|
296
|
+
// qfg-7429.5 denial line, qfg-szte detail: `<short-sha> <reason>`
|
|
297
|
+
errLog(` ${d.commitSha.slice(0, 8)} ${formatDenialDetail(d)}`);
|
|
277
298
|
}
|
|
278
299
|
if (packResult.suggestedRecovery) {
|
|
279
300
|
errLog('');
|
|
@@ -369,7 +390,9 @@ export async function runPush(input, deps) {
|
|
|
369
390
|
if (result.kind === 'denied') {
|
|
370
391
|
errLog(`Push denied for ${result.denials.length} file(s):`);
|
|
371
392
|
for (const d of result.denials) {
|
|
372
|
-
|
|
393
|
+
// qfg-szte: was `<path>: missing permission <perm>`, which dropped the
|
|
394
|
+
// server's reason (the "why") entirely.
|
|
395
|
+
errLog(` ${formatDenialDetail(d)}`);
|
|
373
396
|
}
|
|
374
397
|
throw new PushFatalError(`Push denied for ${result.denials.length} file(s). See errors above.`, 'PUSH_DENIED');
|
|
375
398
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule helpers shared by the CLI's writers (`set-default`, `set-rollout`,
|
|
3
|
+
* `log-level`) and readers (`info`, `cleanup status`) — beads qfg-qjdm and
|
|
4
|
+
* qfg-9kr8.
|
|
5
|
+
*
|
|
6
|
+
* The CLI cannot import app-quonfig code, so this file is a deliberate,
|
|
7
|
+
* small mirror of the server's rulebook. Keep the semantics byte-identical
|
|
8
|
+
* with:
|
|
9
|
+
* - app-quonfig src/lib/domain/rule-display.ts → isCatchAllRule
|
|
10
|
+
* - app-quonfig src/lib/public-api/write-scope.ts → countTargetingRules
|
|
11
|
+
* - app-quonfig src/lib/public-api/log-level-write.ts → upsertFallbackRule
|
|
12
|
+
* Both surfaces write the same documents; a second, subtly different
|
|
13
|
+
* rulebook is a correctness hazard, not a style problem.
|
|
14
|
+
*/
|
|
15
|
+
export type Criterion = {
|
|
16
|
+
operator?: unknown;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type Rule = {
|
|
20
|
+
criteria?: Criterion[];
|
|
21
|
+
value?: unknown;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
export type EnvRules = {
|
|
25
|
+
id: string;
|
|
26
|
+
rules?: Rule[];
|
|
27
|
+
};
|
|
28
|
+
export type ScopedDocument = {
|
|
29
|
+
default?: {
|
|
30
|
+
rules?: Rule[];
|
|
31
|
+
};
|
|
32
|
+
environments?: EnvRules[];
|
|
33
|
+
};
|
|
34
|
+
/** A fresh unconditional rule carrying `value`. */
|
|
35
|
+
export declare function catchAllRule(value: unknown): Rule;
|
|
36
|
+
/**
|
|
37
|
+
* THE ONE DEFINITION of "is this rule unconditional" (qfg-gv54).
|
|
38
|
+
*
|
|
39
|
+
* A catch-all — the base-case rule that serves everyone in its scope — has
|
|
40
|
+
* two live JSON spellings, and every SDK evaluator treats them identically:
|
|
41
|
+
*
|
|
42
|
+
* {criteria: [{operator: 'ALWAYS_TRUE'}]} ← UI, API, MCP, `qfg create`
|
|
43
|
+
* {criteria: []} ← older `qfg set-default` writes
|
|
44
|
+
*
|
|
45
|
+
* Both are in production data and neither is being backfilled (Jeff,
|
|
46
|
+
* 2026-09-02), so every READER accepts both. Criteria are ANDed, so one
|
|
47
|
+
* conditional criterion makes the whole rule conditional.
|
|
48
|
+
*
|
|
49
|
+
* Fails CLOSED: anything unreadable as a rule (no `criteria` key, not an
|
|
50
|
+
* object) is NOT a catch-all — the same choice app-quonfig's
|
|
51
|
+
* `isCatchAllRuleUnknown` makes for raw parsed JSON. On the write path that
|
|
52
|
+
* means a malformed rule is left alone and a fresh fallback is appended,
|
|
53
|
+
* rather than a malformed rule being silently overwritten.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isCatchAllRule(rule: unknown): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* How many real TARGETING rules a scope holds.
|
|
58
|
+
*
|
|
59
|
+
* A targeted scope is conventionally `[…targeting rules, catch-all]`: the
|
|
60
|
+
* trailing unconditional rule is the scope's fallback, not a rule that
|
|
61
|
+
* targets anyone, so it is discounted. ONLY a trailing catch-all is — an
|
|
62
|
+
* unconditional rule anywhere earlier shadows every rule after it, which is
|
|
63
|
+
* a targeting decision in its own right and stays counted.
|
|
64
|
+
*/
|
|
65
|
+
export declare function countTargetingRules(rules: Rule[]): number;
|
|
66
|
+
/**
|
|
67
|
+
* Set the scope's fallback value WITHOUT disturbing its targeting rules:
|
|
68
|
+
* the first catch-all's value is replaced in place (criteria untouched — an
|
|
69
|
+
* existing bare `criteria: []` fallback keeps its spelling, exactly as the
|
|
70
|
+
* server does), and if the scope has no catch-all at all a fresh
|
|
71
|
+
* ALWAYS_TRUE rule is appended at the end. Never mutates `rules`.
|
|
72
|
+
*/
|
|
73
|
+
export declare function upsertFallbackRule(rules: Rule[], value: unknown): Rule[];
|
|
74
|
+
/**
|
|
75
|
+
* The rules a write should start from for one scope.
|
|
76
|
+
*
|
|
77
|
+
* `envName` undefined (or empty) means the document's `default` block, which
|
|
78
|
+
* always exists and is never seeded. A named environment with no block of
|
|
79
|
+
* its own inherits `default.rules` at evaluation time, so writing it starts
|
|
80
|
+
* from a DEEP CLONE of those rules — the same shape the UI produces when an
|
|
81
|
+
* environment stops inheriting. Starting from `[]` instead would silently
|
|
82
|
+
* drop the inherited targeting for that environment.
|
|
83
|
+
*
|
|
84
|
+
* `seeded` is true only when rules were actually copied, so callers do not
|
|
85
|
+
* report a copy that moved nothing.
|
|
86
|
+
*/
|
|
87
|
+
export declare function seedScopeRules(config: ScopedDocument, envName?: string): {
|
|
88
|
+
rules: Rule[];
|
|
89
|
+
seeded: boolean;
|
|
90
|
+
};
|
|
91
|
+
/** The environments array with `envName`'s rules replaced, appending the entry when it has none. */
|
|
92
|
+
export declare function upsertEnvRules(envs: EnvRules[], envName: string, rules: Rule[]): EnvRules[];
|
|
93
|
+
/** '<env> had no rules of its own; copied them from default.' */
|
|
94
|
+
export declare function seededFromDefaultNote(envName: string): string;
|
|
95
|
+
/** What a surgical write kept. Empty when the scope held no targeting rules. */
|
|
96
|
+
export declare function keptTargetingNote(count: number): string;
|
|
97
|
+
/** What a --replace-targeting write destroyed, and where to find it again. */
|
|
98
|
+
export declare function replacedTargetingNote(count: number, previousCommitSha: string): string;
|