@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.
- package/README.md +270 -45
- package/dist/commands/cleanup/list.d.ts +10 -0
- package/dist/commands/cleanup/list.js +157 -0
- package/dist/commands/cleanup/remove.d.ts +13 -0
- package/dist/commands/cleanup/remove.js +234 -0
- package/dist/commands/cleanup/status.d.ts +13 -0
- package/dist/commands/cleanup/status.js +184 -0
- package/dist/commands/cleanup/verify.d.ts +14 -0
- package/dist/commands/cleanup/verify.js +100 -0
- package/dist/commands/cleanup.d.ts +6 -0
- package/dist/commands/cleanup.js +28 -0
- package/dist/commands/config-schema.js +6 -0
- package/dist/commands/create.js +3 -1
- package/dist/commands/delete.js +24 -1
- package/dist/commands/info.js +7 -0
- package/dist/delete/safety-nudge.d.ts +33 -0
- package/dist/delete/safety-nudge.js +50 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +390 -2
- package/package.json +4 -4
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { getEnvironments } from '../../api/get-environments.js';
|
|
3
|
+
import { APICommand } from '../../index.js';
|
|
4
|
+
function startOfDayUtcMs(date) {
|
|
5
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
6
|
+
}
|
|
7
|
+
function deriveWindows(daysAgoByKey, key) {
|
|
8
|
+
const buckets = daysAgoByKey.get(key) ?? new Map();
|
|
9
|
+
let evals_24h = 0;
|
|
10
|
+
let evals_2d = 0;
|
|
11
|
+
let evals_7d = 0;
|
|
12
|
+
let evals_30d = 0;
|
|
13
|
+
let mostRecentDayAgo = null;
|
|
14
|
+
for (const [daysAgo, count] of buckets) {
|
|
15
|
+
if (count <= 0)
|
|
16
|
+
continue;
|
|
17
|
+
if (daysAgo === 0)
|
|
18
|
+
evals_24h += count;
|
|
19
|
+
if (daysAgo <= 1)
|
|
20
|
+
evals_2d += count;
|
|
21
|
+
if (daysAgo <= 6)
|
|
22
|
+
evals_7d += count;
|
|
23
|
+
if (daysAgo <= 29)
|
|
24
|
+
evals_30d += count;
|
|
25
|
+
if (mostRecentDayAgo === null || daysAgo < mostRecentDayAgo) {
|
|
26
|
+
mostRecentDayAgo = daysAgo;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let last_eval = null;
|
|
30
|
+
if (mostRecentDayAgo !== null) {
|
|
31
|
+
const ms = startOfDayUtcMs(new Date()) - mostRecentDayAgo * 86_400_000;
|
|
32
|
+
last_eval = new Date(ms).toISOString().slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
return { evals_24h, evals_2d, evals_7d, evals_30d, last_eval };
|
|
35
|
+
}
|
|
36
|
+
export default class CleanupList extends APICommand {
|
|
37
|
+
static description = `List feature flags marked readyForCleanup=true, with eval volume per window.
|
|
38
|
+
|
|
39
|
+
Columns:
|
|
40
|
+
KEY the flag key
|
|
41
|
+
TYPE the flag's valueType (bool, string, int, etc.)
|
|
42
|
+
EVALS_24H evals counted in today's bucket (latest day)
|
|
43
|
+
EVALS_2D evals in the last 2 calendar days
|
|
44
|
+
EVALS_7D evals in the last 7 calendar days
|
|
45
|
+
EVALS_30D evals in the last 30 calendar days
|
|
46
|
+
LAST_EVAL most recent calendar day with any evals (UTC), blank if none
|
|
47
|
+
CLASS quiet (zero evals in last 2 days) or active
|
|
48
|
+
|
|
49
|
+
Default sort: quiet first, oldest LAST_EVAL at top.
|
|
50
|
+
|
|
51
|
+
Pass --json for the structured object (rows[]). Each row carries the columns
|
|
52
|
+
above plus the raw eval counts your agent can reason about itself.
|
|
53
|
+
|
|
54
|
+
Once you've picked a candidate, run \`qfg cleanup status <key>\` for the
|
|
55
|
+
drill-in or hand off the removal to the qfg-flag-cleanup Claude skill.`;
|
|
56
|
+
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --json'];
|
|
57
|
+
static flags = {
|
|
58
|
+
json: Flags.boolean({ default: false, description: 'Return structured rows for agent consumption' }),
|
|
59
|
+
};
|
|
60
|
+
async run() {
|
|
61
|
+
const { flags } = await this.parse(CleanupList);
|
|
62
|
+
const metadataReq = await this.apiClient.post('/api/v1/metadata/list', { workspaceId: this.workspaceId });
|
|
63
|
+
if (!metadataReq.ok) {
|
|
64
|
+
const errorMsg = metadataReq.error?.error || `Failed to fetch configs: ${metadataReq.status}`;
|
|
65
|
+
return this.err(errorMsg, { serverError: metadataReq.error });
|
|
66
|
+
}
|
|
67
|
+
const metadata = metadataReq.json;
|
|
68
|
+
const readyFlags = metadata.configs.filter((c) => c.type === 'feature_flag' && c.readyForCleanup === true);
|
|
69
|
+
if (readyFlags.length === 0) {
|
|
70
|
+
this.log('No feature flags are marked readyForCleanup. Mark a flag in the UI to start the retirement workflow.');
|
|
71
|
+
if (flags.json) {
|
|
72
|
+
return { rows: [] };
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const environments = await getEnvironments(this);
|
|
77
|
+
// Per-env sparklines, aggregated client-side into a per-key (daysAgo → count) map.
|
|
78
|
+
const todayMs = startOfDayUtcMs(new Date());
|
|
79
|
+
const daysAgoByKey = new Map();
|
|
80
|
+
for (const env of environments) {
|
|
81
|
+
// eslint-disable-next-line no-await-in-loop
|
|
82
|
+
const req = await this.apiClient.post('/api/v1/analytics/sparklines', {
|
|
83
|
+
workspaceId: this.workspaceId,
|
|
84
|
+
environment: env.name,
|
|
85
|
+
});
|
|
86
|
+
if (!req.ok) {
|
|
87
|
+
this.verboseLog('cleanup list', `sparklines failed for env ${env.name}: ${req.status}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const resp = req.json;
|
|
91
|
+
for (const row of resp.rows ?? []) {
|
|
92
|
+
const bucket = daysAgoByKey.get(row.config_key) ?? new Map();
|
|
93
|
+
for (const [i, day] of row.days.entries()) {
|
|
94
|
+
const count = row.counts[i] ?? 0;
|
|
95
|
+
if (count <= 0)
|
|
96
|
+
continue;
|
|
97
|
+
// Day strings are YYYY-MM-DD in UTC (ClickHouse Date). Parsing as UTC midnight.
|
|
98
|
+
const dayMs = Date.parse(`${day}T00:00:00Z`);
|
|
99
|
+
if (Number.isNaN(dayMs))
|
|
100
|
+
continue;
|
|
101
|
+
const daysAgo = Math.round((todayMs - dayMs) / 86_400_000);
|
|
102
|
+
bucket.set(daysAgo, (bucket.get(daysAgo) ?? 0) + count);
|
|
103
|
+
}
|
|
104
|
+
daysAgoByKey.set(row.config_key, bucket);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const rows = readyFlags.map((flag) => {
|
|
108
|
+
const windows = deriveWindows(daysAgoByKey, flag.key);
|
|
109
|
+
const klass = windows.evals_2d === 0 ? 'quiet' : 'active';
|
|
110
|
+
return {
|
|
111
|
+
key: flag.key,
|
|
112
|
+
type: flag.valueType,
|
|
113
|
+
...windows,
|
|
114
|
+
class: klass,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
// Safest-first sort: quiet before active; within each class, oldest last_eval first
|
|
118
|
+
// (null last_eval is "never seen" — sort to the top of the quiet group).
|
|
119
|
+
rows.sort((a, b) => {
|
|
120
|
+
if (a.class !== b.class)
|
|
121
|
+
return a.class === 'quiet' ? -1 : 1;
|
|
122
|
+
if (a.last_eval === b.last_eval)
|
|
123
|
+
return a.key.localeCompare(b.key);
|
|
124
|
+
if (a.last_eval === null)
|
|
125
|
+
return -1;
|
|
126
|
+
if (b.last_eval === null)
|
|
127
|
+
return 1;
|
|
128
|
+
return a.last_eval.localeCompare(b.last_eval);
|
|
129
|
+
});
|
|
130
|
+
if (flags.json) {
|
|
131
|
+
// Returning a JsonObj — the table-style printing below is skipped.
|
|
132
|
+
this.log(this.toSuccessJson({ rows }));
|
|
133
|
+
return { rows: rows };
|
|
134
|
+
}
|
|
135
|
+
// Plain-text table for human consumption.
|
|
136
|
+
const headers = ['KEY', 'TYPE', 'EVALS_24H', 'EVALS_2D', 'EVALS_7D', 'EVALS_30D', 'LAST_EVAL', 'CLASS'];
|
|
137
|
+
const cells = rows.map((r) => [
|
|
138
|
+
r.key,
|
|
139
|
+
r.type,
|
|
140
|
+
String(r.evals_24h),
|
|
141
|
+
String(r.evals_2d),
|
|
142
|
+
String(r.evals_7d),
|
|
143
|
+
String(r.evals_30d),
|
|
144
|
+
r.last_eval ?? '-',
|
|
145
|
+
r.class,
|
|
146
|
+
]);
|
|
147
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((row) => row[i].length)));
|
|
148
|
+
const padRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(' ');
|
|
149
|
+
this.log(padRow(headers));
|
|
150
|
+
this.log(widths.map((w) => '-'.repeat(w)).join(' '));
|
|
151
|
+
for (const row of cells)
|
|
152
|
+
this.log(padRow(row));
|
|
153
|
+
this.log('');
|
|
154
|
+
this.log(`${rows.length} flag(s) marked readyForCleanup. Run \`qfg cleanup status <key>\` for drill-in.`);
|
|
155
|
+
return { rows: rows };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { APICommand } from '../../index.js';
|
|
2
|
+
import { JsonObj } from '../../result.js';
|
|
3
|
+
export default class CleanupRemove extends APICommand {
|
|
4
|
+
static args: {
|
|
5
|
+
name: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static examples: string[];
|
|
9
|
+
static flags: {
|
|
10
|
+
force: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<JsonObj | void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { APICommand } from '../../index.js';
|
|
5
|
+
import { getAppUrl } from '../../util/domain-urls.js';
|
|
6
|
+
import nameArg from '../../util/name-arg.js';
|
|
7
|
+
const SKILL_NAME = 'qfg-flag-cleanup';
|
|
8
|
+
const GITIGNORE_ENTRY = '.qf/cleanup/';
|
|
9
|
+
// SDK client method names worth grepping for in the customer's codebase.
|
|
10
|
+
// Based on the surfaces the official Quonfig SDKs expose (sdk-node /
|
|
11
|
+
// sdk-javascript / sdk-react / sdk-go / sdk-ruby) plus the OpenFeature
|
|
12
|
+
// wrappers. The skill consumes this list, the CLI doesn't run the grep
|
|
13
|
+
// itself — keeping the lookup explicit lets a future SDK ship a new method
|
|
14
|
+
// without breaking older payloads.
|
|
15
|
+
const GREP_PATTERNS = [
|
|
16
|
+
'get',
|
|
17
|
+
'getBoolean',
|
|
18
|
+
'getString',
|
|
19
|
+
'getInt',
|
|
20
|
+
'getDouble',
|
|
21
|
+
'getStringList',
|
|
22
|
+
'getDuration',
|
|
23
|
+
'getJson',
|
|
24
|
+
'getLogLevel',
|
|
25
|
+
'isFeatureEnabled',
|
|
26
|
+
'booleanValue',
|
|
27
|
+
'stringValue',
|
|
28
|
+
'numberValue',
|
|
29
|
+
'objectValue',
|
|
30
|
+
];
|
|
31
|
+
function startOfDayUtcMs(date) {
|
|
32
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
33
|
+
}
|
|
34
|
+
function summarizeSparklines(resp) {
|
|
35
|
+
const todayMs = startOfDayUtcMs(new Date());
|
|
36
|
+
// 4h is finer than the daily bucket the server returns; we approximate it
|
|
37
|
+
// as "today's bucket" (same window the UI sparkline popover shows). It will
|
|
38
|
+
// overlap evals_24h but stays distinct in the payload so the skill / agent
|
|
39
|
+
// can tell "any evals today" from "any evals in last 24 calendar hours".
|
|
40
|
+
let totalEvals4h = 0;
|
|
41
|
+
let totalEvals24h = 0;
|
|
42
|
+
let totalEvals2d = 0;
|
|
43
|
+
let totalEvals7d = 0;
|
|
44
|
+
let totalEvals30d = 0;
|
|
45
|
+
let overallLast = null;
|
|
46
|
+
const envSummaries = [];
|
|
47
|
+
for (const row of resp.rows ?? []) {
|
|
48
|
+
let envTotal = 0;
|
|
49
|
+
let envEvals4h = 0;
|
|
50
|
+
let envEvals24h = 0;
|
|
51
|
+
let envEvals2d = 0;
|
|
52
|
+
let envEvals7d = 0;
|
|
53
|
+
let envEvals30d = 0;
|
|
54
|
+
let envLastDayAgo = null;
|
|
55
|
+
for (const [i, day] of row.days.entries()) {
|
|
56
|
+
const count = row.counts[i] ?? 0;
|
|
57
|
+
if (count <= 0)
|
|
58
|
+
continue;
|
|
59
|
+
const dayMs = Date.parse(`${day}T00:00:00Z`);
|
|
60
|
+
if (Number.isNaN(dayMs))
|
|
61
|
+
continue;
|
|
62
|
+
const daysAgo = Math.round((todayMs - dayMs) / 86_400_000);
|
|
63
|
+
envTotal += count;
|
|
64
|
+
if (daysAgo === 0) {
|
|
65
|
+
envEvals4h += count;
|
|
66
|
+
envEvals24h += count;
|
|
67
|
+
}
|
|
68
|
+
if (daysAgo <= 1)
|
|
69
|
+
envEvals2d += count;
|
|
70
|
+
if (daysAgo <= 6)
|
|
71
|
+
envEvals7d += count;
|
|
72
|
+
if (daysAgo <= 29)
|
|
73
|
+
envEvals30d += count;
|
|
74
|
+
if (envLastDayAgo === null || daysAgo < envLastDayAgo)
|
|
75
|
+
envLastDayAgo = daysAgo;
|
|
76
|
+
}
|
|
77
|
+
const last_eval = envLastDayAgo === null ? null : new Date(todayMs - envLastDayAgo * 86_400_000).toISOString().slice(0, 10);
|
|
78
|
+
envSummaries.push({
|
|
79
|
+
environment: row.environment,
|
|
80
|
+
total: envTotal,
|
|
81
|
+
evals_4h: envEvals4h,
|
|
82
|
+
evals_24h: envEvals24h,
|
|
83
|
+
evals_2d: envEvals2d,
|
|
84
|
+
evals_7d: envEvals7d,
|
|
85
|
+
evals_30d: envEvals30d,
|
|
86
|
+
last_eval,
|
|
87
|
+
});
|
|
88
|
+
totalEvals4h += envEvals4h;
|
|
89
|
+
totalEvals24h += envEvals24h;
|
|
90
|
+
totalEvals2d += envEvals2d;
|
|
91
|
+
totalEvals7d += envEvals7d;
|
|
92
|
+
totalEvals30d += envEvals30d;
|
|
93
|
+
if (envLastDayAgo !== null && (overallLast === null || envLastDayAgo < overallLast)) {
|
|
94
|
+
overallLast = envLastDayAgo;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const last_eval = overallLast === null ? null : new Date(todayMs - overallLast * 86_400_000).toISOString().slice(0, 10);
|
|
98
|
+
return {
|
|
99
|
+
envSummaries,
|
|
100
|
+
totals: {
|
|
101
|
+
evals_4h: totalEvals4h,
|
|
102
|
+
evals_24h: totalEvals24h,
|
|
103
|
+
evals_2d: totalEvals2d,
|
|
104
|
+
evals_7d: totalEvals7d,
|
|
105
|
+
evals_30d: totalEvals30d,
|
|
106
|
+
last_eval,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function ensureGitignoreEntry(cwd) {
|
|
111
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
112
|
+
let existing = '';
|
|
113
|
+
if (fs.existsSync(gitignorePath)) {
|
|
114
|
+
existing = fs.readFileSync(gitignorePath, 'utf8');
|
|
115
|
+
const lines = new Set(existing.split('\n').map((l) => l.trim()));
|
|
116
|
+
if (lines.has('.qf/cleanup') || lines.has('.qf/cleanup/'))
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
|
|
120
|
+
fs.writeFileSync(gitignorePath, `${existing}${sep}${GITIGNORE_ENTRY}\n`, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
export default class CleanupRemove extends APICommand {
|
|
123
|
+
static args = { ...nameArg };
|
|
124
|
+
static description = `Write a cleanup payload for a ready-for-cleanup flag and hand off to the qfg-flag-cleanup Claude skill.
|
|
125
|
+
|
|
126
|
+
Modeled on \`qfg migrate my-code\` — this command never edits source files
|
|
127
|
+
itself. It validates that the flag is marked readyForCleanup=true, refuses to
|
|
128
|
+
proceed if there are still evals_2d > 0 (use --force to override), writes
|
|
129
|
+
\`.qf/cleanup/<key>.json\` describing the flag's current rule shape +
|
|
130
|
+
telemetry, and prints instructions to invoke the qfg-flag-cleanup skill which
|
|
131
|
+
asks the engineer which value should "win" and applies the inlining.
|
|
132
|
+
|
|
133
|
+
The payload deliberately does NOT suggest a winning value; that's the
|
|
134
|
+
engineer's call. Run \`qfg cleanup status <key>\` first if you want to inspect
|
|
135
|
+
telemetry before retiring.`;
|
|
136
|
+
static examples = [
|
|
137
|
+
'<%= config.bin %> <%= command.id %> my.flag.key',
|
|
138
|
+
'<%= config.bin %> <%= command.id %> my.flag.key --force',
|
|
139
|
+
'<%= config.bin %> <%= command.id %> my.flag.key --json',
|
|
140
|
+
];
|
|
141
|
+
static flags = {
|
|
142
|
+
force: Flags.boolean({
|
|
143
|
+
default: false,
|
|
144
|
+
description: 'Skip the evals_2d > 0 safety gate (useful when telemetry is delayed but you know the flag is dead)',
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
async run() {
|
|
148
|
+
const { args, flags } = await this.parse(CleanupRemove);
|
|
149
|
+
const key = args.name;
|
|
150
|
+
if (!key)
|
|
151
|
+
return this.err('Key is required: `qfg cleanup remove <key>`');
|
|
152
|
+
const flagReq = await this.apiClient.post('/api/v1/metadata/getByKey', { workspaceId: this.workspaceId, key });
|
|
153
|
+
if (!flagReq.ok) {
|
|
154
|
+
const errorMsg = flagReq.error?.error || `Failed to fetch flag: ${flagReq.status}`;
|
|
155
|
+
if (flagReq.status === 404)
|
|
156
|
+
return this.err(`Flag ${key} not found`);
|
|
157
|
+
return this.err(errorMsg, { serverError: flagReq.error });
|
|
158
|
+
}
|
|
159
|
+
const flag = flagReq.json;
|
|
160
|
+
if (flag.readyForCleanup !== true) {
|
|
161
|
+
return this.err(`Flag ${key} is not marked readyForCleanup=true. ` +
|
|
162
|
+
`Flip it in the UI first (Flag detail page → mark "Ready for cleanup"), then re-run \`qfg cleanup remove ${key}\`.`);
|
|
163
|
+
}
|
|
164
|
+
const sparkReq = await this.apiClient.post('/api/v1/analytics/configSparklines', {
|
|
165
|
+
workspaceId: this.workspaceId,
|
|
166
|
+
configKey: key,
|
|
167
|
+
});
|
|
168
|
+
if (!sparkReq.ok) {
|
|
169
|
+
return this.err(sparkReq.error?.error || `Failed to fetch sparklines: ${sparkReq.status}`);
|
|
170
|
+
}
|
|
171
|
+
const sparkResp = sparkReq.json;
|
|
172
|
+
const { envSummaries, totals } = summarizeSparklines(sparkResp);
|
|
173
|
+
if (totals.evals_2d > 0 && !flags.force) {
|
|
174
|
+
return this.err(`Flag ${key} still has evals_2d=${totals.evals_2d} (evals in the last 2 days). ` +
|
|
175
|
+
`Refusing to write a cleanup payload — call sites are still hitting it. ` +
|
|
176
|
+
`Run \`qfg cleanup status ${key}\` for the full per-environment breakdown. ` +
|
|
177
|
+
`Use --force if you're sure the flag is dead and the telemetry is stale.`);
|
|
178
|
+
}
|
|
179
|
+
if (!this.workspaceId) {
|
|
180
|
+
return this.err('Workspace ID not found. Please run `qfg login`.');
|
|
181
|
+
}
|
|
182
|
+
const flagUrl = `${getAppUrl()}/workspaces/${this.workspaceId}/flags/${key}`;
|
|
183
|
+
const payload = {
|
|
184
|
+
$schema: 'https://api.quonfig.com/schemas/v1/cleanup-payload.json',
|
|
185
|
+
generatedAt: new Date().toISOString(),
|
|
186
|
+
skill: SKILL_NAME,
|
|
187
|
+
forced: flags.force && totals.evals_2d > 0,
|
|
188
|
+
key: flag.key,
|
|
189
|
+
type: flag.valueType,
|
|
190
|
+
readyForCleanup: true,
|
|
191
|
+
flagUrl,
|
|
192
|
+
default: flag.default ?? { rules: [] },
|
|
193
|
+
environments: flag.environments ?? [],
|
|
194
|
+
evals: totals,
|
|
195
|
+
environmentSparklines: envSummaries,
|
|
196
|
+
grepPatterns: GREP_PATTERNS,
|
|
197
|
+
};
|
|
198
|
+
const cwd = process.cwd();
|
|
199
|
+
const cleanupDir = path.join(cwd, '.qf', 'cleanup');
|
|
200
|
+
fs.mkdirSync(cleanupDir, { recursive: true });
|
|
201
|
+
const payloadPath = path.join(cleanupDir, `${key}.json`);
|
|
202
|
+
fs.writeFileSync(payloadPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
203
|
+
ensureGitignoreEntry(cwd);
|
|
204
|
+
const relPayloadPath = (path.relative(cwd, payloadPath) || payloadPath).split(path.sep).join('/');
|
|
205
|
+
this.log(`Skill: ${SKILL_NAME}`);
|
|
206
|
+
this.log(`Flag: ${flag.key} (${flag.valueType})`);
|
|
207
|
+
this.log(`Flag URL: ${flagUrl}`);
|
|
208
|
+
this.log(`Payload: ${relPayloadPath}`);
|
|
209
|
+
this.log(`Evals (2d): ${totals.evals_2d}${payload.forced ? ' (--force overrode the safety gate)' : ''}`);
|
|
210
|
+
this.log('');
|
|
211
|
+
this.log(`To remove this flag's call sites, invoke Claude with the ${SKILL_NAME} skill:`);
|
|
212
|
+
this.log('');
|
|
213
|
+
this.log(` claude "/${SKILL_NAME} ${key}"`);
|
|
214
|
+
this.log('');
|
|
215
|
+
this.log(`The skill will:`);
|
|
216
|
+
this.log(` 1. Read ${relPayloadPath}`);
|
|
217
|
+
this.log(` 2. Grep the repo for SDK call sites that reference ${flag.key}`);
|
|
218
|
+
this.log(` 3. Ask you which value should "win" (the cleanup direction)`);
|
|
219
|
+
this.log(` 4. Inline that value and delete the unreachable branches`);
|
|
220
|
+
this.log(` 5. Run your formatter / tests and open a PR`);
|
|
221
|
+
this.log('');
|
|
222
|
+
this.log(`Once the PR merges and the SDK redeploys, run \`qfg delete ${key}\` to remove the flag definition.`);
|
|
223
|
+
const output = {
|
|
224
|
+
skill: SKILL_NAME,
|
|
225
|
+
payloadPath: relPayloadPath,
|
|
226
|
+
key: flag.key,
|
|
227
|
+
type: flag.valueType,
|
|
228
|
+
flagUrl,
|
|
229
|
+
forced: payload.forced,
|
|
230
|
+
evals: totals,
|
|
231
|
+
};
|
|
232
|
+
return output;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { APICommand } from '../../index.js';
|
|
2
|
+
import { JsonObj } from '../../result.js';
|
|
3
|
+
export default class CleanupStatus extends APICommand {
|
|
4
|
+
static args: {
|
|
5
|
+
name: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static examples: string[];
|
|
9
|
+
static flags: {
|
|
10
|
+
json: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<JsonObj | void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
function formatRuleValue(value) {
|
|
8
|
+
if (!value)
|
|
9
|
+
return '(no value)';
|
|
10
|
+
// Old format: {type: 'bool', value: true}
|
|
11
|
+
if (typeof value.type === 'string' && value.value !== undefined) {
|
|
12
|
+
return String(value.value);
|
|
13
|
+
}
|
|
14
|
+
// New format: {bool: true} / {string: "..."} / etc.
|
|
15
|
+
for (const k of ['bool', 'string', 'int', 'double']) {
|
|
16
|
+
if (value[k] !== undefined)
|
|
17
|
+
return String(value[k]);
|
|
18
|
+
}
|
|
19
|
+
if (value.stringList !== undefined) {
|
|
20
|
+
return Array.isArray(value.stringList) ? value.stringList.join(',') : JSON.stringify(value.stringList);
|
|
21
|
+
}
|
|
22
|
+
if (value.json !== undefined)
|
|
23
|
+
return JSON.stringify(value.json);
|
|
24
|
+
return JSON.stringify(value);
|
|
25
|
+
}
|
|
26
|
+
function summarizeRules(rules) {
|
|
27
|
+
if (!rules || rules.length === 0)
|
|
28
|
+
return '[inherit default]';
|
|
29
|
+
const fallback = rules.find((r) => r.criteria && r.criteria.length === 1 && r.criteria[0].operator === 'ALWAYS_TRUE');
|
|
30
|
+
const overrides = rules.filter((r) => r !== fallback);
|
|
31
|
+
const fallbackStr = fallback ? formatRuleValue(fallback.value) : formatRuleValue(rules[0].value);
|
|
32
|
+
if (overrides.length === 0)
|
|
33
|
+
return fallbackStr;
|
|
34
|
+
return `${fallbackStr} (+${overrides.length} override rule${overrides.length === 1 ? '' : 's'})`;
|
|
35
|
+
}
|
|
36
|
+
export default class CleanupStatus extends APICommand {
|
|
37
|
+
static args = { ...nameArg };
|
|
38
|
+
static description = `Drill into one ready-for-cleanup flag — show telemetry across all environments and the current rule shape.
|
|
39
|
+
|
|
40
|
+
Use this after \`qfg cleanup list\` to inspect a specific flag before handing
|
|
41
|
+
removal off to the qfg-flag-cleanup Claude skill. The eval counts come from
|
|
42
|
+
analytics.configSparklines (the same backing data the per-flag sparklines on
|
|
43
|
+
the flag detail page use), summed into 24h/2d/7d/30d windows so you can decide
|
|
44
|
+
whether it's safe to retire.
|
|
45
|
+
|
|
46
|
+
Pass --json for the structured object including the full rule shape per
|
|
47
|
+
environment — the cleanup skill consumes this directly.`;
|
|
48
|
+
static examples = [
|
|
49
|
+
'<%= config.bin %> <%= command.id %> my.flag.key',
|
|
50
|
+
'<%= config.bin %> <%= command.id %> my.flag.key --json',
|
|
51
|
+
];
|
|
52
|
+
static flags = {
|
|
53
|
+
json: Flags.boolean({ default: false, description: 'Return structured object for agent consumption' }),
|
|
54
|
+
};
|
|
55
|
+
async run() {
|
|
56
|
+
const { args, flags } = await this.parse(CleanupStatus);
|
|
57
|
+
const key = args.name;
|
|
58
|
+
if (!key)
|
|
59
|
+
return this.err('Key is required: `qfg cleanup status <key>`');
|
|
60
|
+
const flagReq = await this.apiClient.post('/api/v1/metadata/getByKey', { workspaceId: this.workspaceId, key });
|
|
61
|
+
if (!flagReq.ok) {
|
|
62
|
+
const errorMsg = flagReq.error?.error || `Failed to fetch flag: ${flagReq.status}`;
|
|
63
|
+
if (flagReq.status === 404)
|
|
64
|
+
return this.err(`Flag ${key} not found`);
|
|
65
|
+
return this.err(errorMsg, { serverError: flagReq.error });
|
|
66
|
+
}
|
|
67
|
+
const flag = flagReq.json;
|
|
68
|
+
const sparkReq = await this.apiClient.post('/api/v1/analytics/configSparklines', {
|
|
69
|
+
workspaceId: this.workspaceId,
|
|
70
|
+
configKey: key,
|
|
71
|
+
});
|
|
72
|
+
if (!sparkReq.ok) {
|
|
73
|
+
return this.err(sparkReq.error?.error || `Failed to fetch sparklines: ${sparkReq.status}`);
|
|
74
|
+
}
|
|
75
|
+
const sparkResp = sparkReq.json;
|
|
76
|
+
const todayMs = startOfDayUtcMs(new Date());
|
|
77
|
+
const envSummaries = [];
|
|
78
|
+
let totalEvals24h = 0;
|
|
79
|
+
let totalEvals2d = 0;
|
|
80
|
+
let totalEvals7d = 0;
|
|
81
|
+
let totalEvals30d = 0;
|
|
82
|
+
let overallLast = null;
|
|
83
|
+
for (const row of sparkResp.rows ?? []) {
|
|
84
|
+
let envTotal = 0;
|
|
85
|
+
let envEvals24h = 0;
|
|
86
|
+
let envEvals2d = 0;
|
|
87
|
+
let envEvals7d = 0;
|
|
88
|
+
let envEvals30d = 0;
|
|
89
|
+
let envLastDayAgo = null;
|
|
90
|
+
for (const [i, day] of row.days.entries()) {
|
|
91
|
+
const count = row.counts[i] ?? 0;
|
|
92
|
+
if (count <= 0)
|
|
93
|
+
continue;
|
|
94
|
+
const dayMs = Date.parse(`${day}T00:00:00Z`);
|
|
95
|
+
if (Number.isNaN(dayMs))
|
|
96
|
+
continue;
|
|
97
|
+
const daysAgo = Math.round((todayMs - dayMs) / 86_400_000);
|
|
98
|
+
envTotal += count;
|
|
99
|
+
if (daysAgo === 0)
|
|
100
|
+
envEvals24h += count;
|
|
101
|
+
if (daysAgo <= 1)
|
|
102
|
+
envEvals2d += count;
|
|
103
|
+
if (daysAgo <= 6)
|
|
104
|
+
envEvals7d += count;
|
|
105
|
+
if (daysAgo <= 29)
|
|
106
|
+
envEvals30d += count;
|
|
107
|
+
if (envLastDayAgo === null || daysAgo < envLastDayAgo)
|
|
108
|
+
envLastDayAgo = daysAgo;
|
|
109
|
+
}
|
|
110
|
+
const last_eval = envLastDayAgo === null ? null : new Date(todayMs - envLastDayAgo * 86_400_000).toISOString().slice(0, 10);
|
|
111
|
+
envSummaries.push({
|
|
112
|
+
environment: row.environment,
|
|
113
|
+
total: envTotal,
|
|
114
|
+
evals_24h: envEvals24h,
|
|
115
|
+
evals_2d: envEvals2d,
|
|
116
|
+
evals_7d: envEvals7d,
|
|
117
|
+
evals_30d: envEvals30d,
|
|
118
|
+
last_eval,
|
|
119
|
+
});
|
|
120
|
+
totalEvals24h += envEvals24h;
|
|
121
|
+
totalEvals2d += envEvals2d;
|
|
122
|
+
totalEvals7d += envEvals7d;
|
|
123
|
+
totalEvals30d += envEvals30d;
|
|
124
|
+
if (envLastDayAgo !== null && (overallLast === null || envLastDayAgo < overallLast)) {
|
|
125
|
+
overallLast = envLastDayAgo;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Drop env summaries with zero evals so the human view stays terse; the
|
|
129
|
+
// --json output below includes the same array.
|
|
130
|
+
const nonEmptyEnvs = envSummaries.filter((e) => e.total > 0);
|
|
131
|
+
const overallLastEval = overallLast === null ? null : new Date(todayMs - overallLast * 86_400_000).toISOString().slice(0, 10);
|
|
132
|
+
const defaultRulesSummary = summarizeRules(flag.default?.rules);
|
|
133
|
+
const envRules = (flag.environments ?? []).map((env) => ({
|
|
134
|
+
environment: env.id,
|
|
135
|
+
rules: summarizeRules(env.rules),
|
|
136
|
+
}));
|
|
137
|
+
const payload = {
|
|
138
|
+
key: flag.key,
|
|
139
|
+
type: flag.valueType,
|
|
140
|
+
readyForCleanup: flag.readyForCleanup === true,
|
|
141
|
+
defaultRule: defaultRulesSummary,
|
|
142
|
+
environmentRules: envRules,
|
|
143
|
+
evals: {
|
|
144
|
+
evals_24h: totalEvals24h,
|
|
145
|
+
evals_2d: totalEvals2d,
|
|
146
|
+
evals_7d: totalEvals7d,
|
|
147
|
+
evals_30d: totalEvals30d,
|
|
148
|
+
last_eval: overallLastEval,
|
|
149
|
+
},
|
|
150
|
+
environments: nonEmptyEnvs,
|
|
151
|
+
};
|
|
152
|
+
if (flags.json) {
|
|
153
|
+
this.log(this.toSuccessJson(payload));
|
|
154
|
+
return payload;
|
|
155
|
+
}
|
|
156
|
+
this.log(`${flag.key} (${flag.valueType})`);
|
|
157
|
+
this.log(` readyForCleanup: ${flag.readyForCleanup === true ? 'yes' : 'no'}`);
|
|
158
|
+
this.log(` default rule: ${defaultRulesSummary}`);
|
|
159
|
+
if (envRules.length > 0) {
|
|
160
|
+
this.log(' per-environment rules:');
|
|
161
|
+
for (const er of envRules)
|
|
162
|
+
this.log(` - env ${er.environment}: ${er.rules}`);
|
|
163
|
+
}
|
|
164
|
+
this.log('');
|
|
165
|
+
this.log('Evaluations:');
|
|
166
|
+
this.log(` evals_24h: ${totalEvals24h}`);
|
|
167
|
+
this.log(` evals_2d: ${totalEvals2d}`);
|
|
168
|
+
this.log(` evals_7d: ${totalEvals7d}`);
|
|
169
|
+
this.log(` evals_30d: ${totalEvals30d}`);
|
|
170
|
+
this.log(` last_eval: ${overallLastEval ?? '-'}`);
|
|
171
|
+
if (nonEmptyEnvs.length > 0) {
|
|
172
|
+
this.log('');
|
|
173
|
+
this.log('Per-environment:');
|
|
174
|
+
for (const env of nonEmptyEnvs) {
|
|
175
|
+
this.log(` ${env.environment}: total=${env.total} 24h=${env.evals_24h} 2d=${env.evals_2d} 7d=${env.evals_7d} 30d=${env.evals_30d} last=${env.last_eval ?? '-'}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
this.log('');
|
|
180
|
+
this.log('No evaluations in the retained window — this flag looks quiet.');
|
|
181
|
+
}
|
|
182
|
+
return payload;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { APICommand } from '../../index.js';
|
|
2
|
+
import { JsonObj } from '../../result.js';
|
|
3
|
+
export default class CleanupVerify extends APICommand {
|
|
4
|
+
static args: {
|
|
5
|
+
name: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static examples: string[];
|
|
9
|
+
static flags: {
|
|
10
|
+
days: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
11
|
+
json: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
12
|
+
};
|
|
13
|
+
run(): Promise<JsonObj | void>;
|
|
14
|
+
}
|