flagrm 0.4.0 → 0.5.0
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/CHANGELOG.md +48 -0
- package/README.md +320 -103
- package/dist/cli.js +58 -3
- package/dist/core/baseline.js +9 -4
- package/dist/core/change-filter.js +7 -4
- package/dist/core/config-snapshot.js +17 -0
- package/dist/core/config.js +94 -0
- package/dist/core/detect.js +2 -2
- package/dist/core/doctor.js +14 -1
- package/dist/core/git.js +11 -1
- package/dist/core/hook.js +13 -2
- package/dist/core/install.js +70 -15
- package/dist/core/markdown.js +57 -0
- package/dist/core/report.js +30 -0
- package/dist/core/run-cache.js +13 -3
- package/dist/core/scan.js +30 -3
- package/dist/core/scope.js +3 -3
- package/dist/core/sources/launchdarkly.js +331 -0
- package/dist/core/sources/types.js +13 -0
- package/dist/core/stale.js +218 -0
- package/dist/core/test-runner.js +2 -2
- package/dist/core/types.js +2 -0
- package/dist/core/verify/test-results.js +2 -3
- package/package.json +4 -4
- package/skills/flagrm-remove/SKILL.md +2 -2
- package/skills/flagrm-remove/batch.md +1 -1
- package/skills/flagrm-review/SKILL.md +49 -0
- package/skills/flagrm-verify/SKILL.md +0 -54
package/dist/core/markdown.js
CHANGED
|
@@ -68,3 +68,60 @@ export function verifyMarkdown(report, root) {
|
|
|
68
68
|
}
|
|
69
69
|
return `${out.join("\n")}\n`;
|
|
70
70
|
}
|
|
71
|
+
/** One environment in a few words: `production true 45d`, `staging targeted (1 rule(s))`. */
|
|
72
|
+
export function staleEnvironmentText(e) {
|
|
73
|
+
if (e.serves === "targeted")
|
|
74
|
+
return `${e.environment} targeted (${e.detail})`;
|
|
75
|
+
const days = e.days === undefined ? "" : ` ${e.days}d${e.sinceSource === "lastModified" ? "*" : ""}`;
|
|
76
|
+
return `${e.environment} ${JSON.stringify(e.value)}${days}`;
|
|
77
|
+
}
|
|
78
|
+
/** The conditions a report applied, in one line. */
|
|
79
|
+
export function staleConditionsText(report) {
|
|
80
|
+
const c = report.conditions;
|
|
81
|
+
const parts = [
|
|
82
|
+
`boolean, true in ${c.environments.join(", ") || "no environment"} for ${c.minDaysOn}+ days`,
|
|
83
|
+
...(c.temporaryOnly ? ["temporary"] : []),
|
|
84
|
+
...(c.tags.include.length ? [`tags ${c.tags.include.join(", ")}`] : []),
|
|
85
|
+
...(c.tags.exclude.length ? [`not tagged ${c.tags.exclude.join(", ")}`] : []),
|
|
86
|
+
];
|
|
87
|
+
return parts.join("; ");
|
|
88
|
+
}
|
|
89
|
+
const STALE_TITLES = {
|
|
90
|
+
remove: "Ready to remove",
|
|
91
|
+
keep: "Keep",
|
|
92
|
+
unreferenced: "Not referenced in this repository",
|
|
93
|
+
};
|
|
94
|
+
/** `flagrm stale --md`: the flags per verdict, for a pull request or a pipeline summary. */
|
|
95
|
+
export function staleMarkdown(report) {
|
|
96
|
+
const count = (v) => report.flags.filter((f) => f.verdict === v).length;
|
|
97
|
+
const out = [
|
|
98
|
+
`### flagrm stale: ${report.project} — ${count("remove")} ready to remove`,
|
|
99
|
+
"",
|
|
100
|
+
`_Conditions: ${staleConditionsText(report)}. Evaluated ${report.evaluatedAt}._`,
|
|
101
|
+
];
|
|
102
|
+
const cell = (text) => text.replace(/\|/g, "\\|");
|
|
103
|
+
for (const verdict of ["remove", "keep"]) {
|
|
104
|
+
const flags = report.flags.filter((f) => f.verdict === verdict);
|
|
105
|
+
if (!flags.length)
|
|
106
|
+
continue;
|
|
107
|
+
const columns = ["Flag", "Code name", "Environments", ...(verdict === "keep" ? ["Reasons"] : [])];
|
|
108
|
+
out.push("", `**${STALE_TITLES[verdict]} (${flags.length})**`, "", `| ${columns.join(" | ")} |`, `|${"---|".repeat(columns.length)}`);
|
|
109
|
+
for (const f of flags) {
|
|
110
|
+
const cells = [
|
|
111
|
+
f.key ? `\`${f.key}\`` : "—",
|
|
112
|
+
f.codeName ? `\`${f.codeName}\`` : "—",
|
|
113
|
+
cell(f.environments.map(staleEnvironmentText).join(", ") || "—"),
|
|
114
|
+
...(verdict === "keep" ? [cell(f.reasons.join("; "))] : []),
|
|
115
|
+
];
|
|
116
|
+
out.push(`| ${cells.join(" | ")} |`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const unreferenced = report.flags.filter((f) => f.verdict === "unreferenced").map((f) => f.key ?? "");
|
|
120
|
+
if (unreferenced.length) {
|
|
121
|
+
out.push("", `**${STALE_TITLES.unreferenced} (${unreferenced.length}):** ${codeList(unreferenced)}`, "", "_Another repository may still read these; flagrm doesn't recommend archiving them._");
|
|
122
|
+
}
|
|
123
|
+
if (report.flags.some((f) => f.environments.some((e) => e.sinceSource === "lastModified"))) {
|
|
124
|
+
out.push("", "_\\* days since the environment last changed: the audit log had no targeting change to date._");
|
|
125
|
+
}
|
|
126
|
+
return out.join("\n");
|
|
127
|
+
}
|
package/dist/core/report.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
+
import { staleConditionsText, staleEnvironmentText } from "./markdown.js";
|
|
2
3
|
import { reusedNote } from "./run-cache.js";
|
|
3
4
|
import { plural, relativePath } from "./util.js";
|
|
4
5
|
/** A path relative to the working directory, with `/` separators on every platform. */
|
|
@@ -136,6 +137,7 @@ const STEP_STYLE = {
|
|
|
136
137
|
updated: pc.green,
|
|
137
138
|
unchanged: pc.dim,
|
|
138
139
|
skipped: pc.dim,
|
|
140
|
+
deleted: pc.yellow,
|
|
139
141
|
};
|
|
140
142
|
/** One line per `init`/`update` step: `created .claude/skills/flagrm-remove`. */
|
|
141
143
|
export function printSteps(steps) {
|
|
@@ -189,3 +191,31 @@ export function printScope(setups, root, written) {
|
|
|
189
191
|
console.log(pc.dim(" A solution filter can't help here: install the SDK or change global.json."));
|
|
190
192
|
}
|
|
191
193
|
}
|
|
194
|
+
const STALE_STYLE = {
|
|
195
|
+
remove: { title: "Ready to remove", color: pc.green },
|
|
196
|
+
keep: { title: "Keep", color: pc.yellow },
|
|
197
|
+
unreferenced: { title: "Not referenced in this repository (another may still read them)", color: pc.dim },
|
|
198
|
+
};
|
|
199
|
+
/** `flagrm stale`: the flags grouped by verdict, each with its environments and reasons. */
|
|
200
|
+
export function printStale(report) {
|
|
201
|
+
console.log(pc.bold(`LaunchDarkly project ${report.project}`) + pc.dim(` — ${staleConditionsText(report)}`));
|
|
202
|
+
for (const verdict of ["remove", "keep", "unreferenced"]) {
|
|
203
|
+
const flags = report.flags.filter((f) => f.verdict === verdict);
|
|
204
|
+
if (!flags.length)
|
|
205
|
+
continue;
|
|
206
|
+
const { title, color } = STALE_STYLE[verdict];
|
|
207
|
+
console.log(`\n${color(pc.bold(`${title} (${flags.length})`))}`);
|
|
208
|
+
for (const f of flags) {
|
|
209
|
+
const name = f.key && f.codeName && f.key !== f.codeName ? `${f.key} → ${f.codeName}` : (f.key ?? f.codeName);
|
|
210
|
+
const envs = f.environments.map(staleEnvironmentText).join(", ");
|
|
211
|
+
console.log(` ${name}${envs ? pc.dim(` ${envs}`) : ""}`);
|
|
212
|
+
for (const reason of verdict === "unreferenced" ? [] : f.reasons)
|
|
213
|
+
console.log(pc.dim(` ${reason}`));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (report.flags.length === 0)
|
|
217
|
+
console.log(pc.dim("\nNo flags."));
|
|
218
|
+
if (report.flags.some((f) => f.environments.some((e) => e.sinceSource === "lastModified"))) {
|
|
219
|
+
console.log(pc.dim("\n* days since the environment last changed: the audit log had no targeting change to date."));
|
|
220
|
+
}
|
|
221
|
+
}
|
package/dist/core/run-cache.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import
|
|
9
|
+
import { globSync } from "tinyglobby";
|
|
10
10
|
import { flagDir, matchingFiles, runChecks } from "./baseline.js";
|
|
11
11
|
import { changeFilter } from "./change-filter.js";
|
|
12
12
|
import { configSnapshot, projectCommands } from "./config-snapshot.js";
|
|
@@ -69,7 +69,12 @@ export function saveRuns(root, flag, projects, key, runs, createdAt = new Date()
|
|
|
69
69
|
}
|
|
70
70
|
/** Forget every saved set, of any flag, for this tree: a fresh run on it failed, so they may hide a flaky test or a changed machine. */
|
|
71
71
|
export function dropRuns(root, key) {
|
|
72
|
-
for (const file of
|
|
72
|
+
for (const file of globSync(".flagrm/*/verify/runs.json", {
|
|
73
|
+
expandDirectories: false,
|
|
74
|
+
cwd: root,
|
|
75
|
+
absolute: true,
|
|
76
|
+
dot: true,
|
|
77
|
+
})) {
|
|
73
78
|
if (readJson(file)?.key === key)
|
|
74
79
|
fs.rmSync(file, { force: true });
|
|
75
80
|
}
|
|
@@ -81,7 +86,12 @@ export function findRuns(root, projects, key, needed) {
|
|
|
81
86
|
const commands = JSON.stringify(configSnapshot(projects).projects);
|
|
82
87
|
const version = packageVersion();
|
|
83
88
|
let best;
|
|
84
|
-
for (const file of
|
|
89
|
+
for (const file of globSync(".flagrm/*/verify/runs.json", {
|
|
90
|
+
expandDirectories: false,
|
|
91
|
+
cwd: root,
|
|
92
|
+
absolute: true,
|
|
93
|
+
dot: true,
|
|
94
|
+
})) {
|
|
85
95
|
const saved = readJson(file);
|
|
86
96
|
if (!saved || !Array.isArray(saved.runs) || typeof saved.createdAt !== "string")
|
|
87
97
|
continue;
|
package/dist/core/scan.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
|
-
import
|
|
3
|
+
import { globSync, isDynamicPattern } from "tinyglobby";
|
|
3
4
|
export const BACKEND_GLOBS = [
|
|
4
5
|
"**/*.cs",
|
|
5
6
|
"**/*.cshtml",
|
|
@@ -19,11 +20,37 @@ export const IGNORE_GLOBS = [
|
|
|
19
20
|
"**/.angular/**",
|
|
20
21
|
"**/.git/**",
|
|
21
22
|
];
|
|
22
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Files matching an absolute path or glob, such as a `testResults` pattern,
|
|
25
|
+
* as absolute paths with `/`. The leading directories that exist are taken
|
|
26
|
+
* literally and become the glob's `cwd`, so a repository path containing
|
|
27
|
+
* glob characters (`/work/p[x]/TestResults/*.trx`) still matches.
|
|
28
|
+
*/
|
|
29
|
+
export function globAbsolute(pattern) {
|
|
30
|
+
const segments = (path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern).split("/");
|
|
31
|
+
let base = segments[0] === "" ? "/" : `${segments[0]}/`;
|
|
32
|
+
let i = 1;
|
|
33
|
+
for (; i < segments.length - 1; i++) {
|
|
34
|
+
const dir = path.posix.join(base, segments[i]);
|
|
35
|
+
if (isDynamicPattern(segments[i]) && !isDirectory(dir))
|
|
36
|
+
break;
|
|
37
|
+
base = dir;
|
|
38
|
+
}
|
|
39
|
+
return globSync(segments.slice(i).join("/"), { expandDirectories: false, cwd: base, absolute: true, dot: true });
|
|
40
|
+
}
|
|
41
|
+
function isDirectory(dir) {
|
|
42
|
+
try {
|
|
43
|
+
return fs.statSync(dir).isDirectory();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Absolute paths with the platform's separators (tinyglobby returns `/` on Windows too), so they match `path.join`. */
|
|
23
50
|
export function listProjectFiles(root, globs, overrides) {
|
|
24
51
|
const patterns = [...globs, ...(overrides?.include ?? [])];
|
|
25
52
|
const ignore = [...IGNORE_GLOBS, ...(overrides?.exclude ?? [])];
|
|
26
|
-
return
|
|
53
|
+
return globSync(patterns, { expandDirectories: false, cwd: root, ignore, absolute: true, dot: false }).map((file) => path.normalize(file));
|
|
27
54
|
}
|
|
28
55
|
export function escapeRegExp(s) {
|
|
29
56
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
package/dist/core/scope.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
-
import
|
|
11
|
+
import { globSync } from "tinyglobby";
|
|
12
12
|
import YAML from "yaml";
|
|
13
13
|
import { sdkProblem, solutionSetup } from "../adapters/dotnet/setup.js";
|
|
14
14
|
import { projectCommands } from "./config-snapshot.js";
|
|
@@ -81,8 +81,8 @@ function solutionFor(ctx, commands) {
|
|
|
81
81
|
return { note: `can't read ${arg}` };
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
-
const found =
|
|
85
|
-
//
|
|
84
|
+
const found = globSync("*.{sln,slnx}", { expandDirectories: false, cwd: ctx.root, absolute: true });
|
|
85
|
+
// tinyglobby returns forward slashes, also on Windows.
|
|
86
86
|
if (found.length === 1)
|
|
87
87
|
return { solution: path.normalize(found[0]) };
|
|
88
88
|
return { note: found.length ? "several solutions: name one in build/test" : "no solution file found" };
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a LaunchDarkly project through the REST API (GET only): its
|
|
3
|
+
* environments, every non-archived flag with full targeting, and, for the
|
|
4
|
+
* flags that could be removed, the audit log to date the last targeting change.
|
|
5
|
+
* `fetch` and `sleep` are injectable so tests never touch the network or wait.
|
|
6
|
+
*/
|
|
7
|
+
import { SourceError } from "./types.js";
|
|
8
|
+
export const LD_API_VERSION = "20240415";
|
|
9
|
+
const TIMEOUT_MS = 30_000;
|
|
10
|
+
const MAX_RETRIES = 5;
|
|
11
|
+
const MAX_WAIT_MS = 60_000;
|
|
12
|
+
const PAGE_SIZE = 100;
|
|
13
|
+
/** Environments requested per flags listing: kept small, since each adds full targeting to every flag. */
|
|
14
|
+
const ENVIRONMENTS_PER_LISTING = 3;
|
|
15
|
+
/** The audit log returns at most 20 entries per page. */
|
|
16
|
+
const AUDIT_PAGE_SIZE = 20;
|
|
17
|
+
const AUDIT_PAGES = 5;
|
|
18
|
+
/**
|
|
19
|
+
* Audit-log actions that don't change what an environment serves. Any other
|
|
20
|
+
* action, including one LaunchDarkly adds later, resets `since`: an unknown
|
|
21
|
+
* action can then only shorten the time a flag counts as ON, never lengthen it.
|
|
22
|
+
*/
|
|
23
|
+
export const NON_TARGETING_ACTIONS = new Set([
|
|
24
|
+
"updateName",
|
|
25
|
+
"updateDescription",
|
|
26
|
+
"updateTags",
|
|
27
|
+
"updateTemporary",
|
|
28
|
+
"updateMaintainer",
|
|
29
|
+
"updateDeprecated",
|
|
30
|
+
"updateIncludeInSnippet",
|
|
31
|
+
"updateClientSideFlagAvailability",
|
|
32
|
+
"updateFlagCustomProperties",
|
|
33
|
+
"updateFlagLinks",
|
|
34
|
+
"updateTrackEvents",
|
|
35
|
+
"updateTrackEventsFallthrough",
|
|
36
|
+
"updateFlagSalt",
|
|
37
|
+
"createApprovalRequest",
|
|
38
|
+
"reviewApprovalRequest",
|
|
39
|
+
"deleteApprovalRequest",
|
|
40
|
+
"updateApprovalRequest",
|
|
41
|
+
"createTriggers",
|
|
42
|
+
"updateTriggers",
|
|
43
|
+
"deleteTriggers",
|
|
44
|
+
]);
|
|
45
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
46
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
47
|
+
export class LaunchDarklySource {
|
|
48
|
+
config;
|
|
49
|
+
options;
|
|
50
|
+
fetch;
|
|
51
|
+
sleep;
|
|
52
|
+
now;
|
|
53
|
+
constructor(config, options) {
|
|
54
|
+
this.config = config;
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
57
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
58
|
+
this.now = options.now ?? Date.now;
|
|
59
|
+
}
|
|
60
|
+
async read(wantsHistory) {
|
|
61
|
+
const environments = await this.environments();
|
|
62
|
+
const flags = await this.flags(environments);
|
|
63
|
+
for (const { flag, raw } of flags) {
|
|
64
|
+
if (flag.unrecognized)
|
|
65
|
+
continue;
|
|
66
|
+
const history = wantsHistory(flag);
|
|
67
|
+
for (const [env, state] of Object.entries(flag.environments)) {
|
|
68
|
+
if (state.serves.kind !== "fixed")
|
|
69
|
+
continue;
|
|
70
|
+
const lastModified = raw[env].lastModified;
|
|
71
|
+
const audit = history ? await this.lastTargetingChange(flag.key, env) : undefined;
|
|
72
|
+
state.since = iso(audit ?? lastModified);
|
|
73
|
+
state.sinceSource = audit === undefined ? "lastModified" : "audit";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { environments, flags: flags.map((f) => f.flag) };
|
|
77
|
+
}
|
|
78
|
+
/** The checked environments: the configured ones, which must exist, or all of the project's. */
|
|
79
|
+
async environments() {
|
|
80
|
+
const project = encodeURIComponent(this.config.project);
|
|
81
|
+
const keys = [];
|
|
82
|
+
const environmentsUrl = `/api/v2/projects/${project}/environments?limit=${PAGE_SIZE}`;
|
|
83
|
+
for await (const item of this.pages(environmentsUrl, this.projectNotFound())) {
|
|
84
|
+
if (typeof item.key === "string")
|
|
85
|
+
keys.push(item.key);
|
|
86
|
+
}
|
|
87
|
+
const wanted = this.config.environments;
|
|
88
|
+
if (!wanted)
|
|
89
|
+
return keys;
|
|
90
|
+
const unknown = wanted.filter((e) => !keys.includes(e));
|
|
91
|
+
if (unknown.length) {
|
|
92
|
+
throw new Error(`launchdarkly.environments: ${unknown.map((e) => `"${e}"`).join(", ")} not in LaunchDarkly project ` +
|
|
93
|
+
`"${this.config.project}". Its environments: ${keys.join(", ") || "none"}.`);
|
|
94
|
+
}
|
|
95
|
+
return wanted;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Every non-archived flag with what each environment serves; `raw` keeps each environment's JSON.
|
|
99
|
+
* The listing leaves archived flags out unless asked for them (`filter=state:archived`), so no filter is sent.
|
|
100
|
+
*/
|
|
101
|
+
async flags(environments) {
|
|
102
|
+
const project = encodeURIComponent(this.config.project);
|
|
103
|
+
const byKey = new Map();
|
|
104
|
+
for (let i = 0; i < environments.length; i += ENVIRONMENTS_PER_LISTING) {
|
|
105
|
+
const batch = environments.slice(i, i + ENVIRONMENTS_PER_LISTING);
|
|
106
|
+
const envParams = batch.map((e) => `&env=${encodeURIComponent(e)}`).join("");
|
|
107
|
+
const flagsUrl = `/api/v2/flags/${project}?summary=0&limit=${PAGE_SIZE}${envParams}`;
|
|
108
|
+
for await (const item of this.pages(flagsUrl, this.projectNotFound())) {
|
|
109
|
+
const key = typeof item.key === "string" ? item.key : undefined;
|
|
110
|
+
if (!key)
|
|
111
|
+
continue;
|
|
112
|
+
const entry = byKey.get(key) ?? { item, envs: {} };
|
|
113
|
+
if (isObject(item.environments))
|
|
114
|
+
Object.assign(entry.envs, item.environments);
|
|
115
|
+
byKey.set(key, entry);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return [...byKey.values()].map(({ item, envs }) => ({ flag: parseFlag(item, envs, environments), raw: envs }));
|
|
119
|
+
}
|
|
120
|
+
/** Epoch ms of the latest audit entry for the flag in `env` that may have changed targeting; undefined when none. */
|
|
121
|
+
async lastTargetingChange(flagKey, env) {
|
|
122
|
+
const spec = `proj/${this.config.project}:env/${env}:flag/${flagKey}`;
|
|
123
|
+
let before;
|
|
124
|
+
for (let page = 0; page < AUDIT_PAGES; page++) {
|
|
125
|
+
const query = `spec=${encodeURIComponent(spec)}&limit=${AUDIT_PAGE_SIZE}${before ? `&before=${before}` : ""}`;
|
|
126
|
+
const body = await this.get(`/api/v2/auditlog?${query}`);
|
|
127
|
+
const items = Array.isArray(body.items) ? body.items.filter(isObject) : [];
|
|
128
|
+
const dated = items.filter((i) => typeof i.date === "number");
|
|
129
|
+
// The log lists newest first, but take the latest targeting entry of the page rather than rely on it.
|
|
130
|
+
const changes = dated.filter(changesTargeting).map((i) => i.date);
|
|
131
|
+
if (changes.length)
|
|
132
|
+
return Math.max(...changes);
|
|
133
|
+
if (dated.length < AUDIT_PAGE_SIZE)
|
|
134
|
+
return undefined;
|
|
135
|
+
before = Math.min(...dated.map((i) => i.date));
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
/** Each item of a paginated listing, following `_links.next`. */
|
|
140
|
+
async *pages(first, notFound) {
|
|
141
|
+
for (let next = first; next;) {
|
|
142
|
+
const body = await this.get(next, notFound);
|
|
143
|
+
if (!Array.isArray(body.items))
|
|
144
|
+
throw new SourceError(`LaunchDarkly returned no items list for GET ${next}`);
|
|
145
|
+
yield* body.items.filter(isObject);
|
|
146
|
+
const links = isObject(body._links) && isObject(body._links.next) ? body._links.next : undefined;
|
|
147
|
+
next = typeof links?.href === "string" ? links.href : undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* GET `target` as JSON, retrying rate limits; every failure is a {@link SourceError}, a 404 with `notFound`
|
|
152
|
+
* as its message when given. `target` is a path rooted at `baseUrl`, or a `_links` URL on the same origin.
|
|
153
|
+
*/
|
|
154
|
+
async get(target, notFound) {
|
|
155
|
+
const url = this.resolve(target);
|
|
156
|
+
const endpoint = new URL(url).pathname;
|
|
157
|
+
for (let attempt = 0;; attempt++) {
|
|
158
|
+
let res;
|
|
159
|
+
try {
|
|
160
|
+
res = await this.fetch(url, {
|
|
161
|
+
method: "GET",
|
|
162
|
+
headers: { Authorization: this.options.token, "LD-API-Version": LD_API_VERSION, Accept: "application/json" },
|
|
163
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
throw new SourceError(this.redact(networkMessage(err, endpoint)));
|
|
168
|
+
}
|
|
169
|
+
if (res.status === 429 && attempt < MAX_RETRIES) {
|
|
170
|
+
await this.sleep(this.retryWait(res.headers));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (res.ok) {
|
|
174
|
+
try {
|
|
175
|
+
const body = await res.json();
|
|
176
|
+
if (isObject(body))
|
|
177
|
+
return body;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// reported below
|
|
181
|
+
}
|
|
182
|
+
throw new SourceError(`LaunchDarkly returned a response that isn't a JSON object for GET ${endpoint}`);
|
|
183
|
+
}
|
|
184
|
+
const message = res.status === 404 && notFound ? `${notFound} (404) at GET ${endpoint}` : undefined;
|
|
185
|
+
throw new SourceError(this.redact(message ?? this.statusMessage(res.status, endpoint)));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The URL for a path (`/api/v2/...`, appended to `baseUrl`) or an absolute `_links` URL. An absolute URL
|
|
190
|
+
* must be on `baseUrl`'s origin: the token is sent with the request, so it never goes to another host.
|
|
191
|
+
*/
|
|
192
|
+
resolve(target) {
|
|
193
|
+
if (target.startsWith("/"))
|
|
194
|
+
return `${this.config.baseUrl}${target}`;
|
|
195
|
+
let url;
|
|
196
|
+
try {
|
|
197
|
+
url = new URL(target);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new SourceError(`LaunchDarkly returned a link flagrm can't follow: ${target}`);
|
|
201
|
+
}
|
|
202
|
+
if (url.origin !== new URL(this.config.baseUrl).origin) {
|
|
203
|
+
throw new SourceError(`LaunchDarkly returned a link to another host (${url.origin}); not following it`);
|
|
204
|
+
}
|
|
205
|
+
return url.href;
|
|
206
|
+
}
|
|
207
|
+
projectNotFound() {
|
|
208
|
+
return `LaunchDarkly project "${this.config.project}" not found`;
|
|
209
|
+
}
|
|
210
|
+
statusMessage(status, endpoint) {
|
|
211
|
+
const { project, tokenEnv } = this.config;
|
|
212
|
+
if (status === 401)
|
|
213
|
+
return `LaunchDarkly rejected the token in ${tokenEnv} (401) for GET ${endpoint}`;
|
|
214
|
+
if (status === 403) {
|
|
215
|
+
return `The token in ${tokenEnv} may not read LaunchDarkly project "${project}" (403) for GET ${endpoint}`;
|
|
216
|
+
}
|
|
217
|
+
if (status === 404)
|
|
218
|
+
return `LaunchDarkly found nothing (404) at GET ${endpoint}`;
|
|
219
|
+
if (status === 429)
|
|
220
|
+
return `LaunchDarkly kept rate-limiting (429) after ${MAX_RETRIES} retries for GET ${endpoint}`;
|
|
221
|
+
return `LaunchDarkly answered ${status} for GET ${endpoint}`;
|
|
222
|
+
}
|
|
223
|
+
/** Milliseconds to wait before retrying a 429: the longest the headers ask for, at most a minute. */
|
|
224
|
+
retryWait(headers) {
|
|
225
|
+
const waits = [];
|
|
226
|
+
for (const name of ["X-Ratelimit-Reset", "X-Ratelimit-Auth-Token-Reset"]) {
|
|
227
|
+
const reset = Number(headers.get(name));
|
|
228
|
+
if (headers.get(name) && Number.isFinite(reset))
|
|
229
|
+
waits.push(reset - this.now());
|
|
230
|
+
}
|
|
231
|
+
const retryAfter = Number(headers.get("Retry-After"));
|
|
232
|
+
if (headers.get("Retry-After") && Number.isFinite(retryAfter))
|
|
233
|
+
waits.push(retryAfter * 1000);
|
|
234
|
+
const wait = waits.length ? Math.max(...waits) : 1000;
|
|
235
|
+
return Math.min(MAX_WAIT_MS, Math.max(0, wait));
|
|
236
|
+
}
|
|
237
|
+
/** Belt and braces: no message may carry the token, whatever an error echoes back. */
|
|
238
|
+
redact(message) {
|
|
239
|
+
const { token } = this.options;
|
|
240
|
+
return token ? message.split(token).join("***") : message;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function networkMessage(err, endpoint) {
|
|
244
|
+
const e = err;
|
|
245
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
246
|
+
return `LaunchDarkly didn't answer within ${TIMEOUT_MS / 1000} s for GET ${endpoint}`;
|
|
247
|
+
}
|
|
248
|
+
const cause = e?.cause?.code ?? e?.cause?.message ?? e?.message ?? String(err);
|
|
249
|
+
return `Couldn't reach LaunchDarkly (${cause}) for GET ${endpoint}`;
|
|
250
|
+
}
|
|
251
|
+
/** Whether an audit entry may have changed what the environment serves. */
|
|
252
|
+
function changesTargeting(entry) {
|
|
253
|
+
const accesses = Array.isArray(entry.accesses) ? entry.accesses.filter(isObject) : [];
|
|
254
|
+
// An entry without readable actions counts as a change: unknown can only shorten the ON time.
|
|
255
|
+
if (accesses.length === 0)
|
|
256
|
+
return true;
|
|
257
|
+
return accesses.some((a) => typeof a.action !== "string" || !NON_TARGETING_ACTIONS.has(a.action));
|
|
258
|
+
}
|
|
259
|
+
const nonEmpty = (v) => Array.isArray(v) && v.length > 0;
|
|
260
|
+
/**
|
|
261
|
+
* What one environment serves, from its targeting: `fixed` only when every
|
|
262
|
+
* context gets one variation. Undefined when the data isn't what flagrm expects.
|
|
263
|
+
*/
|
|
264
|
+
export function servedValue(env, variations) {
|
|
265
|
+
const variation = (index) => typeof index === "number" && Number.isInteger(index) && index >= 0 && index < variations.length
|
|
266
|
+
? { kind: "fixed", value: variations[index].value }
|
|
267
|
+
: undefined;
|
|
268
|
+
for (const field of ["targets", "contextTargets", "rules", "prerequisites"]) {
|
|
269
|
+
if (env[field] !== undefined && env[field] !== null && !Array.isArray(env[field]))
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
if (typeof env.on !== "boolean")
|
|
273
|
+
return undefined;
|
|
274
|
+
if (!env.on) {
|
|
275
|
+
if (env.offVariation === undefined || env.offVariation === null) {
|
|
276
|
+
return { kind: "targeted", detail: "targeting off without an off variation (the code's fallback is served)" };
|
|
277
|
+
}
|
|
278
|
+
return variation(env.offVariation);
|
|
279
|
+
}
|
|
280
|
+
const count = (field) => (Array.isArray(env[field]) ? env[field].length : 0);
|
|
281
|
+
if (nonEmpty(env.prerequisites))
|
|
282
|
+
return { kind: "targeted", detail: `${count("prerequisites")} prerequisite(s)` };
|
|
283
|
+
if (nonEmpty(env.rules))
|
|
284
|
+
return { kind: "targeted", detail: `${count("rules")} rule(s)` };
|
|
285
|
+
if (nonEmpty(env.targets) || nonEmpty(env.contextTargets))
|
|
286
|
+
return { kind: "targeted", detail: "individual targets" };
|
|
287
|
+
if (!isObject(env.fallthrough))
|
|
288
|
+
return undefined;
|
|
289
|
+
if (env.fallthrough.rollout !== undefined && env.fallthrough.rollout !== null) {
|
|
290
|
+
return { kind: "targeted", detail: "a percentage rollout" };
|
|
291
|
+
}
|
|
292
|
+
return variation(env.fallthrough.variation);
|
|
293
|
+
}
|
|
294
|
+
/** A {@link SourceFlag} from one flags-listing item; `unrecognized` says what didn't parse. */
|
|
295
|
+
export function parseFlag(item, envs, environments) {
|
|
296
|
+
const key = String(item.key);
|
|
297
|
+
const flag = {
|
|
298
|
+
key,
|
|
299
|
+
name: typeof item.name === "string" ? item.name : key,
|
|
300
|
+
kind: typeof item.kind === "string" ? item.kind : "unknown",
|
|
301
|
+
temporary: item.temporary === true,
|
|
302
|
+
tags: Array.isArray(item.tags) ? item.tags.filter((t) => typeof t === "string") : [],
|
|
303
|
+
creationDate: typeof item.creationDate === "number" ? iso(item.creationDate) : "",
|
|
304
|
+
environments: {},
|
|
305
|
+
};
|
|
306
|
+
const problems = [];
|
|
307
|
+
if (typeof item.kind !== "string")
|
|
308
|
+
problems.push("no kind");
|
|
309
|
+
if (typeof item.temporary !== "boolean")
|
|
310
|
+
problems.push("no temporary marker");
|
|
311
|
+
if (!Array.isArray(item.variations) || !item.variations.every(isObject))
|
|
312
|
+
problems.push("no variations");
|
|
313
|
+
for (const env of environments) {
|
|
314
|
+
const raw = envs[env];
|
|
315
|
+
if (!isObject(raw)) {
|
|
316
|
+
problems.push(`no targeting for environment "${env}"`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const serves = Array.isArray(item.variations) ? servedValue(raw, item.variations) : undefined;
|
|
320
|
+
if (!serves)
|
|
321
|
+
problems.push(`unreadable targeting in "${env}"`);
|
|
322
|
+
else if (serves.kind === "fixed" && typeof raw.lastModified !== "number") {
|
|
323
|
+
problems.push(`no lastModified in "${env}"`);
|
|
324
|
+
}
|
|
325
|
+
else
|
|
326
|
+
flag.environments[env] = { serves };
|
|
327
|
+
}
|
|
328
|
+
if (problems.length)
|
|
329
|
+
flag.unrecognized = `unrecognized LaunchDarkly data: ${problems.join(", ")}`;
|
|
330
|
+
return flag;
|
|
331
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A flag service flagrm reads flags from, for `flagrm stale`. Read-only: a
|
|
3
|
+
* source never writes to the service. LaunchDarkly is the only one today;
|
|
4
|
+
* another service is a new file implementing {@link FlagSource}.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The service couldn't be asked: unreachable, timed out, rejected the token,
|
|
8
|
+
* unknown project, rate limit retries used up. `flagrm stale` exits 3 on it.
|
|
9
|
+
* Messages name the endpoint, never the token.
|
|
10
|
+
*/
|
|
11
|
+
export class SourceError extends Error {
|
|
12
|
+
name = "SourceError";
|
|
13
|
+
}
|