@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.
@@ -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
- this.log(' (no workspaces yet — `qfg workspace create` to add one)');
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 — we verbose-log and continue.
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
- for (const [orgId] of orgEntries) {
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
- this.verboseLog('workspace: token refresh failed for org', { orgId, error: String(error) });
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 list = await this.fetchWorkspaces(jwt);
109
- for (const w of list) {
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
- this.verboseLog('workspace: userWorkspaces/list failed', { error: String(error) });
132
- return [];
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 entries = await this.fetchWorkspaces(jwt);
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 json ?? error;
31
- }
32
- if (typeof error === 'string') {
33
- return this.error(error, { code: 'ERR', exit: 1 });
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(this.toErrorJson(error), { code: 'ERR', exit: 1 });
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.log(err.message);
76
- this.exit(err.exitCode || 1);
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 = {
@@ -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 ────────────────────────────────────────────────────
@@ -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>;
@@ -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> <path> -- <reason>, requires <requiredPermission>`
276
- errLog(` ${d.commitSha.slice(0, 8)} ${d.path} -- ${d.reason}, requires ${d.requiredPermission}`);
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
- errLog(` ${d.path}: missing permission ${d.requiredPermission}`);
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;