@quonfig/cli 0.0.54 → 0.0.57

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,100 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { APICommand } from '../../index.js';
3
+ import nameArg from '../../util/name-arg.js';
4
+ function startOfDayUtcMs(date) {
5
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
6
+ }
7
+ export default class CleanupVerify extends APICommand {
8
+ static args = { ...nameArg };
9
+ static description = `Confirm zero evals for a flag in the trailing N days (default 7). Exits 0 if safe to delete, non-zero otherwise.
10
+
11
+ Read-only gate intended for chaining with \`qfg delete\` after a cleanup PR
12
+ has merged and the SDK has redeployed:
13
+
14
+ qfg cleanup verify my.flag.key && qfg delete my.flag.key
15
+
16
+ The trailing window is stricter than the \`qfg cleanup remove\` 2-day gate on
17
+ purpose — \`remove\` only writes a payload describing the flag, but \`delete\`
18
+ permanently removes the flag definition, so we want a longer quiet period
19
+ before chaining. Bump --days N if you want a more conservative window.`;
20
+ static examples = [
21
+ '<%= config.bin %> <%= command.id %> my.flag.key',
22
+ '<%= config.bin %> <%= command.id %> my.flag.key --days 14',
23
+ '<%= config.bin %> <%= command.id %> my.flag.key --json',
24
+ '<%= config.bin %> <%= command.id %> my.flag.key && <%= config.bin %> delete my.flag.key',
25
+ ];
26
+ static flags = {
27
+ days: Flags.integer({
28
+ default: 7,
29
+ description: 'Number of trailing calendar days that must have zero evals',
30
+ }),
31
+ json: Flags.boolean({ default: false, description: 'Return structured object for agent consumption' }),
32
+ };
33
+ async run() {
34
+ const { args, flags } = await this.parse(CleanupVerify);
35
+ const key = args.name;
36
+ if (!key)
37
+ return this.err('Key is required: `qfg cleanup verify <key>`');
38
+ if (flags.days <= 0)
39
+ return this.err('--days must be a positive integer');
40
+ const flagReq = await this.apiClient.post('/api/v1/metadata/getByKey', { workspaceId: this.workspaceId, key });
41
+ if (!flagReq.ok) {
42
+ const errorMsg = flagReq.error?.error || `Failed to fetch flag: ${flagReq.status}`;
43
+ if (flagReq.status === 404)
44
+ return this.err(`Flag ${key} not found`);
45
+ return this.err(errorMsg, { serverError: flagReq.error });
46
+ }
47
+ const sparkReq = await this.apiClient.post('/api/v1/analytics/configSparklines', {
48
+ workspaceId: this.workspaceId,
49
+ configKey: key,
50
+ });
51
+ if (!sparkReq.ok) {
52
+ return this.err(sparkReq.error?.error || `Failed to fetch sparklines: ${sparkReq.status}`);
53
+ }
54
+ const sparkResp = sparkReq.json;
55
+ const todayMs = startOfDayUtcMs(new Date());
56
+ let evals = 0;
57
+ let mostRecentDayAgo = null;
58
+ for (const row of sparkResp.rows ?? []) {
59
+ for (const [i, day] of row.days.entries()) {
60
+ const count = row.counts[i] ?? 0;
61
+ if (count <= 0)
62
+ continue;
63
+ const dayMs = Date.parse(`${day}T00:00:00Z`);
64
+ if (Number.isNaN(dayMs))
65
+ continue;
66
+ const daysAgo = Math.round((todayMs - dayMs) / 86_400_000);
67
+ // --days 7 means "today (daysAgo=0) through 6 days ago" (a 7-day window).
68
+ // --days 1 means "today only".
69
+ if (daysAgo < 0 || daysAgo > flags.days - 1)
70
+ continue;
71
+ evals += count;
72
+ if (mostRecentDayAgo === null || daysAgo < mostRecentDayAgo)
73
+ mostRecentDayAgo = daysAgo;
74
+ }
75
+ }
76
+ const lastEval = mostRecentDayAgo === null ? null : new Date(todayMs - mostRecentDayAgo * 86_400_000).toISOString().slice(0, 10);
77
+ const safe = evals === 0;
78
+ const payload = {
79
+ key,
80
+ daysChecked: flags.days,
81
+ evals,
82
+ lastEval,
83
+ safe,
84
+ };
85
+ if (flags.json) {
86
+ // --json always emits the payload and exits 0; agents read `safe` to decide.
87
+ // Shell `verify && delete` chaining uses the text mode below where exit code
88
+ // matters for the shell short-circuit.
89
+ this.log(this.toSuccessJson(payload));
90
+ return payload;
91
+ }
92
+ if (safe) {
93
+ this.log(`${key}: safe to delete — 0 evals in the last ${flags.days} day(s).`);
94
+ return payload;
95
+ }
96
+ return this.err(`${key}: ${evals} eval(s) in the last ${flags.days} day(s), latest ${lastEval}. ` +
97
+ `Refusing to confirm safe-to-delete. Bump --days, wait for traffic to drain, or run ` +
98
+ `\`qfg cleanup status ${key}\` for the full per-environment breakdown.`);
99
+ }
100
+ }
@@ -0,0 +1,6 @@
1
+ import { BaseCommand } from '../index.js';
2
+ export default class Cleanup extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,28 @@
1
+ import { BaseCommand } from '../index.js';
2
+ export default class Cleanup extends BaseCommand {
3
+ static description = `Retire feature flags that have done their job.
4
+
5
+ The cleanup workflow turns a flag's "Ready for cleanup" marker into an end-to-end
6
+ removal flow: surface the flags that are safe to retire, hand the actual code
7
+ removal to the qfg-flag-cleanup Claude skill, then delete the flag definition
8
+ once the call sites are gone.
9
+
10
+ Lifecycle (high-level):
11
+ 1. Owner flips readyForCleanup=true in the UI (already exists)
12
+ 2. qfg cleanup list see candidates + telemetry
13
+ 3. qfg cleanup status <key> drill into one flag
14
+ 4. qfg cleanup remove <key> handoff to the cleanup skill
15
+ 5. PR merges, SDK redeploys, telemetry confirms 0 evals
16
+ 6. qfg cleanup verify <key> (optional) confirm safe
17
+ 7. qfg delete <key> remove the flag definition`;
18
+ static examples = [
19
+ '<%= config.bin %> cleanup list',
20
+ '<%= config.bin %> cleanup list --json',
21
+ '<%= config.bin %> cleanup status my.flag.key',
22
+ ];
23
+ async run() {
24
+ this.log('Use one of the cleanup subcommands:');
25
+ this.log(' qfg cleanup list # ready-for-cleanup flags + telemetry');
26
+ this.log(' qfg cleanup status <key> # drill into one flag');
27
+ }
28
+ }
@@ -134,6 +134,11 @@ Weights are integers out of 100,000 (not percentages):
134
134
 
135
135
  hashByPropertyName controls the bucket assignment property (default: "user.key").
136
136
 
137
+ ────────────────────────────────────────────────────────────
138
+ LIFECYCLE FIELDS
139
+ ────────────────────────────────────────────────────────────
140
+ readyForCleanup (boolean) Marks a feature_flag as safe to retire. See \`qfg cleanup\` for the retirement workflow.
141
+
137
142
  ────────────────────────────────────────────────────────────
138
143
  RELATED COMMANDS
139
144
  ────────────────────────────────────────────────────────────
@@ -143,6 +148,7 @@ RELATED COMMANDS
143
148
  qfg pull --dir ./config clone workspace to edit JSON directly
144
149
  qfg verify ./config validate all JSON files before pushing
145
150
  qfg info my.flag inspect current values and rules
151
+ qfg cleanup list list flags marked readyForCleanup=true
146
152
  qfg config-schema --json-schema print the machine-readable JSON Schema
147
153
  `.trim();
148
154
  export default class ConfigSchema extends BaseCommand {
@@ -41,7 +41,9 @@ Log levels:
41
41
  # Log-level keys must start with "${LOG_LEVEL_KEY_PREFIX}".
42
42
  # For per-logger targeting, create ONE log-level config per service and add
43
43
  # rules on the "quonfig-sdk-logging.key" context property (e.g.
44
- # PROP_STARTS_WITH_ONE_OF MyPackage.) rather than one config per logger.`;
44
+ # PROP_STARTS_WITH_ONE_OF MyPackage.) rather than one config per logger.
45
+
46
+ When this flag has done its job, mark readyForCleanup: true in the UI and run \`qfg cleanup list\` to retire it.`;
45
47
  static examples = [
46
48
  '<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag',
47
49
  '<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag --value=true',
@@ -1,6 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { APICommand } from '../index.js';
3
- import { confirmTypedSlug } from '../push/confirm.js';
3
+ import { checkRecentEvalsSafetyNudge } from '../delete/safety-nudge.js';
4
+ import { confirmTypedSlug, confirmYesNo } from '../push/confirm.js';
4
5
  import isInteractive from '../util/is-interactive.js';
5
6
  import { checkmark } from '../util/color.js';
6
7
  import { loadTokens } from '../util/token-storage.js';
@@ -72,6 +73,28 @@ Examples
72
73
  if (!isInteractive(flags)) {
73
74
  return this.err(`Refusing to delete ${key} without confirmation. Pass --yes or run interactively.`);
74
75
  }
76
+ if (item.type === 'feature_flag') {
77
+ const safety = await checkRecentEvalsSafetyNudge({
78
+ key,
79
+ fetchSparklines: async () => {
80
+ const res = await this.apiClient.post('/api/v1/analytics/configSparklines', {
81
+ workspaceId: this.workspaceId,
82
+ configKey: key,
83
+ });
84
+ if (!res.ok) {
85
+ const errMsg = res.error?.error ?? `HTTP ${res.status}`;
86
+ return { ok: false, error: errMsg };
87
+ }
88
+ const json = res.json;
89
+ return { ok: true, rows: json.rows ?? [] };
90
+ },
91
+ prompt: (msg) => confirmYesNo(msg),
92
+ warn: (msg) => this.log(`warning: ${msg}`),
93
+ });
94
+ if (!safety.proceed) {
95
+ return this.err(`Aborted: ${key} still has recent evals. Run \`qfg cleanup remove ${key}\` first, then retry.`);
96
+ }
97
+ }
75
98
  this.log(`About to delete ${item.type.replaceAll('_', ' ')}: ${key}`);
76
99
  this.log('This rewrites the workspace repo and is destructive.');
77
100
  const confirmed = await confirmTypedSlug(key, `Type "${key}" to confirm: `);
@@ -65,6 +65,7 @@ Related commands:
65
65
  }
66
66
  const fullConfig = configRequest.json;
67
67
  this.verboseLog('Full config:', fullConfig);
68
+ const readyForCleanup = fullConfig?.readyForCleanup === true;
68
69
  // Get environments
69
70
  const environments = await getEnvironments(this);
70
71
  const appUrl = getAppUrl();
@@ -76,6 +77,9 @@ Related commands:
76
77
  this.log(url);
77
78
  this.log('');
78
79
  const json = { url };
80
+ if (readyForCleanup) {
81
+ json.readyForCleanup = true;
82
+ }
79
83
  json.values = this.parseConfig(fullConfig, environments, url);
80
84
  this.log('');
81
85
  // Fetch evaluation stats if needed
@@ -85,6 +89,9 @@ Related commands:
85
89
  json.evaluations = evalStats;
86
90
  }
87
91
  }
92
+ if (readyForCleanup) {
93
+ this.log(`→ This flag is marked for cleanup. Run \`qfg cleanup status ${key}\` for telemetry, \`qfg cleanup remove ${key}\` to start the removal handoff.`);
94
+ }
88
95
  return { [key]: json };
89
96
  }
90
97
  displayEvaluationStats(statsPerEnv, environments) {
@@ -0,0 +1,33 @@
1
+ export interface ConfigSparklineRow {
2
+ counts: number[];
3
+ days: string[];
4
+ environment: string;
5
+ }
6
+ interface SparklinesOk {
7
+ ok: true;
8
+ rows: ConfigSparklineRow[];
9
+ }
10
+ interface SparklinesErr {
11
+ error: string;
12
+ ok: false;
13
+ }
14
+ export type SparklinesResult = SparklinesErr | SparklinesOk;
15
+ export interface SafetyNudgeDeps {
16
+ fetchSparklines: () => Promise<SparklinesResult>;
17
+ key: string;
18
+ prompt: (message: string) => Promise<boolean>;
19
+ warn: (message: string) => void;
20
+ }
21
+ export interface SafetyNudgeResult {
22
+ evals24h: number;
23
+ proceed: boolean;
24
+ telemetryFailed: boolean;
25
+ }
26
+ /**
27
+ * Sum the eval counts in today's UTC calendar bucket across all environment
28
+ * rows in a configSparklines response. Matches the `evals_24h` semantics used
29
+ * in `qfg cleanup remove` (today-only bucket from the daily-grouped sparkline).
30
+ */
31
+ export declare function countEvalsLast24h(rows: ConfigSparklineRow[]): number;
32
+ export declare function checkRecentEvalsSafetyNudge(deps: SafetyNudgeDeps): Promise<SafetyNudgeResult>;
33
+ export {};
@@ -0,0 +1,50 @@
1
+ // Safety nudge for `qfg delete` — catches the wrong-order cleanup mistake
2
+ // before it ships. If a flag still has eval traffic in the last 24h, we prompt
3
+ // the user with "did you mean `qfg cleanup remove` first?" before letting
4
+ // `qfg delete` proceed to the destructive typed-slug confirmation.
5
+ //
6
+ // This module is the testable core; src/commands/delete.ts wires the real
7
+ // apiClient + confirmYesNo to it.
8
+ function startOfDayUtcMs(date) {
9
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
10
+ }
11
+ /**
12
+ * Sum the eval counts in today's UTC calendar bucket across all environment
13
+ * rows in a configSparklines response. Matches the `evals_24h` semantics used
14
+ * in `qfg cleanup remove` (today-only bucket from the daily-grouped sparkline).
15
+ */
16
+ export function countEvalsLast24h(rows) {
17
+ const todayMs = startOfDayUtcMs(new Date());
18
+ let total = 0;
19
+ for (const row of rows) {
20
+ for (const [i, day] of row.days.entries()) {
21
+ const count = row.counts[i] ?? 0;
22
+ if (count <= 0)
23
+ continue;
24
+ const dayMs = Date.parse(`${day}T00:00:00Z`);
25
+ if (Number.isNaN(dayMs))
26
+ continue;
27
+ const daysAgo = Math.round((todayMs - dayMs) / 86_400_000);
28
+ if (daysAgo !== 0)
29
+ continue;
30
+ total += count;
31
+ }
32
+ }
33
+ return total;
34
+ }
35
+ export async function checkRecentEvalsSafetyNudge(deps) {
36
+ const { fetchSparklines, key, prompt, warn } = deps;
37
+ const sparkResult = await fetchSparklines();
38
+ if (!sparkResult.ok) {
39
+ warn(`Could not check recent evals for ${key} (${sparkResult.error}). Proceeding anyway.`);
40
+ return { evals24h: 0, proceed: true, telemetryFailed: true };
41
+ }
42
+ const evals24h = countEvalsLast24h(sparkResult.rows);
43
+ if (evals24h === 0) {
44
+ return { evals24h: 0, proceed: true, telemetryFailed: false };
45
+ }
46
+ const message = `This flag still has ${evals24h} eval(s) in the last 24h. ` +
47
+ `Did you mean \`qfg cleanup remove ${key}\` first to get the call sites out? [y/N] `;
48
+ const confirmed = await prompt(message);
49
+ return { evals24h, proceed: confirmed, telemetryFailed: false };
50
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.54";
1
+ declare const _default: "0.0.57";
2
2
  export default _default;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
2
- export default '0.0.54';
2
+ export default '0.0.57';