@nanoflags/cli 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +189 -2
- package/dist/bin.js +839 -0
- package/dist/chunk-KGDBDK5S.js +174 -0
- package/dist/chunk-TJEVOUI3.js +74 -0
- package/dist/cleanup-QYR3DM6S.js +371 -0
- package/dist/refs-CYG6GHJL.js +19 -0
- package/package.json +55 -3
package/dist/bin.js
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
CliError,
|
|
4
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
5
|
+
EXIT,
|
|
6
|
+
clip,
|
|
7
|
+
show,
|
|
8
|
+
table,
|
|
9
|
+
unifiedDiff,
|
|
10
|
+
walk
|
|
11
|
+
} from "./chunk-KGDBDK5S.js";
|
|
12
|
+
|
|
13
|
+
// src/main.ts
|
|
14
|
+
import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
16
|
+
import { parseArgs } from "node:util";
|
|
17
|
+
|
|
18
|
+
// ../engine/src/index.ts
|
|
19
|
+
var encoder = new TextEncoder();
|
|
20
|
+
function murmur3(input, seed = 0) {
|
|
21
|
+
const data = encoder.encode(input.normalize("NFC"));
|
|
22
|
+
const c1 = 3432918353;
|
|
23
|
+
const c2 = 461845907;
|
|
24
|
+
let h = seed >>> 0;
|
|
25
|
+
const blocks = data.length >> 2;
|
|
26
|
+
for (let i = 0; i < blocks; i++) {
|
|
27
|
+
const o = i * 4;
|
|
28
|
+
let k2 = data[o] | data[o + 1] << 8 | data[o + 2] << 16 | data[o + 3] << 24;
|
|
29
|
+
k2 = Math.imul(k2, c1);
|
|
30
|
+
k2 = k2 << 15 | k2 >>> 17;
|
|
31
|
+
k2 = Math.imul(k2, c2);
|
|
32
|
+
h ^= k2;
|
|
33
|
+
h = h << 13 | h >>> 19;
|
|
34
|
+
h = Math.imul(h, 5) + 3864292196 | 0;
|
|
35
|
+
}
|
|
36
|
+
let k = 0;
|
|
37
|
+
const tail = blocks * 4;
|
|
38
|
+
switch (data.length & 3) {
|
|
39
|
+
case 3:
|
|
40
|
+
k ^= data[tail + 2] << 16;
|
|
41
|
+
// falls through
|
|
42
|
+
case 2:
|
|
43
|
+
k ^= data[tail + 1] << 8;
|
|
44
|
+
// falls through
|
|
45
|
+
case 1:
|
|
46
|
+
k ^= data[tail];
|
|
47
|
+
k = Math.imul(k, c1);
|
|
48
|
+
k = k << 15 | k >>> 17;
|
|
49
|
+
k = Math.imul(k, c2);
|
|
50
|
+
h ^= k;
|
|
51
|
+
}
|
|
52
|
+
h ^= data.length;
|
|
53
|
+
h ^= h >>> 16;
|
|
54
|
+
h = Math.imul(h, 2246822507);
|
|
55
|
+
h ^= h >>> 13;
|
|
56
|
+
h = Math.imul(h, 3266489909);
|
|
57
|
+
h ^= h >>> 16;
|
|
58
|
+
return h >>> 0;
|
|
59
|
+
}
|
|
60
|
+
function bucketOf(seed, identity) {
|
|
61
|
+
return murmur3(`${seed}:${identity}`) % 1e4 / 100;
|
|
62
|
+
}
|
|
63
|
+
function own(obj, key2) {
|
|
64
|
+
return obj != null && Object.prototype.hasOwnProperty.call(obj, key2) ? obj[key2] : void 0;
|
|
65
|
+
}
|
|
66
|
+
function looseEq(a, b) {
|
|
67
|
+
if (a === void 0 || b === void 0) return false;
|
|
68
|
+
if (typeof a === "object" || typeof b === "object") return JSON.stringify(a) === JSON.stringify(b);
|
|
69
|
+
return a === b;
|
|
70
|
+
}
|
|
71
|
+
function num(v) {
|
|
72
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
73
|
+
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function matchCondition(c, ctx, segments, depth = 0) {
|
|
77
|
+
if (c.op === "segment" || c.op === "not_segment") {
|
|
78
|
+
const seg = typeof c.value === "string" ? own(segments, c.value) : void 0;
|
|
79
|
+
const inSeg = seg !== void 0 && depth < 4 && matchAll(seg.match, seg.conditions, ctx, segments, depth + 1);
|
|
80
|
+
return c.op === "segment" ? inSeg : seg !== void 0 && !inSeg;
|
|
81
|
+
}
|
|
82
|
+
const actual = own(ctx, c.attribute);
|
|
83
|
+
switch (c.op) {
|
|
84
|
+
case "exists":
|
|
85
|
+
return actual !== void 0 && actual !== null;
|
|
86
|
+
case "not_exists":
|
|
87
|
+
return actual === void 0 || actual === null;
|
|
88
|
+
case "eq":
|
|
89
|
+
return looseEq(actual, c.value);
|
|
90
|
+
case "neq":
|
|
91
|
+
return actual !== void 0 && !looseEq(actual, c.value);
|
|
92
|
+
case "in":
|
|
93
|
+
return Array.isArray(c.value) && c.value.some((v) => looseEq(actual, v));
|
|
94
|
+
case "not_in":
|
|
95
|
+
return actual !== void 0 && Array.isArray(c.value) && !c.value.some((v) => looseEq(actual, v));
|
|
96
|
+
case "contains":
|
|
97
|
+
return typeof actual === "string" && typeof c.value === "string" && actual.includes(c.value);
|
|
98
|
+
case "starts_with":
|
|
99
|
+
return typeof actual === "string" && typeof c.value === "string" && actual.startsWith(c.value);
|
|
100
|
+
case "ends_with":
|
|
101
|
+
return typeof actual === "string" && typeof c.value === "string" && actual.endsWith(c.value);
|
|
102
|
+
case "gt":
|
|
103
|
+
case "gte":
|
|
104
|
+
case "lt":
|
|
105
|
+
case "lte": {
|
|
106
|
+
const a = num(actual);
|
|
107
|
+
const b = num(c.value);
|
|
108
|
+
if (a === null || b === null) return false;
|
|
109
|
+
return c.op === "gt" ? a > b : c.op === "gte" ? a >= b : c.op === "lt" ? a < b : a <= b;
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function matchAll(mode, conditions, ctx, segments, depth = 0) {
|
|
116
|
+
if (conditions.length === 0) return true;
|
|
117
|
+
return mode === "any" ? conditions.some((c) => matchCondition(c, ctx, segments, depth)) : conditions.every((c) => matchCondition(c, ctx, segments, depth));
|
|
118
|
+
}
|
|
119
|
+
function describeCondition(c) {
|
|
120
|
+
const v = Array.isArray(c.value) ? c.value.join(", ") : c.value === void 0 ? "" : String(c.value);
|
|
121
|
+
const words = {
|
|
122
|
+
eq: "=",
|
|
123
|
+
neq: "\u2260",
|
|
124
|
+
in: "in",
|
|
125
|
+
not_in: "not in",
|
|
126
|
+
contains: "contains",
|
|
127
|
+
starts_with: "starts with",
|
|
128
|
+
ends_with: "ends with",
|
|
129
|
+
gt: ">",
|
|
130
|
+
gte: "\u2265",
|
|
131
|
+
lt: "<",
|
|
132
|
+
lte: "\u2264",
|
|
133
|
+
exists: "exists",
|
|
134
|
+
not_exists: "does not exist",
|
|
135
|
+
segment: "in segment",
|
|
136
|
+
not_segment: "not in segment"
|
|
137
|
+
};
|
|
138
|
+
if (c.op === "segment" || c.op === "not_segment") return `${words[c.op]} ${v}`;
|
|
139
|
+
if (c.op === "exists" || c.op === "not_exists") return `${c.attribute} ${words[c.op]}`;
|
|
140
|
+
return `${c.attribute} ${words[c.op]} ${v}`;
|
|
141
|
+
}
|
|
142
|
+
function evaluate(bundle, flagKey, ctx, fallback) {
|
|
143
|
+
const revision = bundle?.revision ?? 0;
|
|
144
|
+
const trace = [];
|
|
145
|
+
try {
|
|
146
|
+
const flag = own(bundle?.flags, flagKey);
|
|
147
|
+
if (!bundle || !flag) {
|
|
148
|
+
trace.push({ kind: "result", title: `Returned ${fmt(fallback)}`, detail: bundle ? "Flag is not in this configuration. The application fallback applies." : "No configuration is loaded. The application fallback applies." });
|
|
149
|
+
return { flagKey, value: fallback, reason: "flag_not_found", revision, trace };
|
|
150
|
+
}
|
|
151
|
+
trace.push({ kind: "config", title: "Configuration loaded", detail: revision > 0 ? `r${revision}` : "draft" });
|
|
152
|
+
if (!flag.enabled) {
|
|
153
|
+
trace.push({ kind: "result", title: `Returned ${fmt(flag.off)}`, detail: "Flag is disabled in this environment." });
|
|
154
|
+
return { flagKey, value: flag.off, reason: "off", revision, trace };
|
|
155
|
+
}
|
|
156
|
+
for (let i = 0; i < flag.rules.length; i++) {
|
|
157
|
+
const rule = flag.rules[i];
|
|
158
|
+
const matched = matchAll(rule.match, rule.conditions, ctx, bundle.segments);
|
|
159
|
+
const label = `Rule ${String(i + 1).padStart(2, "0")}`;
|
|
160
|
+
const summary = rule.conditions.length ? rule.conditions.map(describeCondition).join(rule.match === "any" ? " OR " : " AND ") : "all contexts";
|
|
161
|
+
trace.push({ kind: "rule", title: matched ? `${label} matched` : `${label} did not match`, detail: summary, matched });
|
|
162
|
+
if (!matched) continue;
|
|
163
|
+
if (rule.serve.type === "fixed") {
|
|
164
|
+
trace.push({ kind: "result", title: `Returned ${fmt(rule.serve.value)}`, detail: `${label} serves a fixed value.` });
|
|
165
|
+
return { flagKey, value: rule.serve.value, reason: "rule_match", ruleIndex: i, ruleId: rule.id, revision, trace };
|
|
166
|
+
}
|
|
167
|
+
const attr = rule.serve.bucketBy ?? "key";
|
|
168
|
+
const identity = own(ctx, attr);
|
|
169
|
+
if (identity === void 0 || identity === null || identity === "") {
|
|
170
|
+
trace.push({ kind: "allocation", title: "Allocation skipped", detail: `Context has no "${attr}" to bucket by; treated as outside the allocation.`, matched: false });
|
|
171
|
+
trace.push({ kind: "result", title: `Returned ${fmt(rule.serve.otherwise)}`, detail: "Remaining eligible users." });
|
|
172
|
+
return { flagKey, value: rule.serve.otherwise, reason: "rule_match", ruleIndex: i, ruleId: rule.id, inRollout: false, revision, trace };
|
|
173
|
+
}
|
|
174
|
+
const bucket = bucketOf(rule.serve.seed ?? flagKey, String(identity));
|
|
175
|
+
const inRollout = bucket < rule.serve.percentage;
|
|
176
|
+
trace.push({ kind: "allocation", title: "Allocation evaluated", detail: `${rule.serve.percentage}% rollout \xB7 ${attr} ${inRollout ? "inside" : "outside"} allocation (bucket ${bucket.toFixed(2)})`, matched: inRollout });
|
|
177
|
+
const value = inRollout ? rule.serve.value : rule.serve.otherwise;
|
|
178
|
+
trace.push({ kind: "result", title: `Returned ${fmt(value)}`, detail: inRollout ? `Inside the ${rule.serve.percentage}% allocation.` : "Remaining eligible users." });
|
|
179
|
+
return { flagKey, value, reason: "rule_match", ruleIndex: i, ruleId: rule.id, inRollout, bucket, revision, trace };
|
|
180
|
+
}
|
|
181
|
+
trace.push({ kind: "result", title: `Returned ${fmt(flag.default)}`, detail: "No rule matched. The flag default applies." });
|
|
182
|
+
return { flagKey, value: flag.default, reason: "default", revision, trace };
|
|
183
|
+
} catch (e) {
|
|
184
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
185
|
+
trace.push({ kind: "result", title: `Returned ${fmt(fallback)}`, detail: `Evaluation error: ${error}. The application fallback applies.` });
|
|
186
|
+
return { flagKey, value: fallback, reason: "error", revision, trace, error };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function fmt(v) {
|
|
190
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/api.ts
|
|
194
|
+
var DEFAULT_BASE_URL = "https://edge.nanoflags.io";
|
|
195
|
+
var ApiError = class extends Error {
|
|
196
|
+
constructor(message, status, code) {
|
|
197
|
+
super(message);
|
|
198
|
+
this.status = status;
|
|
199
|
+
this.code = code;
|
|
200
|
+
this.name = "ApiError";
|
|
201
|
+
}
|
|
202
|
+
status;
|
|
203
|
+
code;
|
|
204
|
+
};
|
|
205
|
+
function isExport(v) {
|
|
206
|
+
if (typeof v !== "object" || v === null) return false;
|
|
207
|
+
const e = v;
|
|
208
|
+
return typeof e.project === "object" && e.project !== null && Array.isArray(e.flags) && e.flags.every((f) => typeof f === "object" && f !== null && typeof f.key === "string");
|
|
209
|
+
}
|
|
210
|
+
function keyKind(key2) {
|
|
211
|
+
return key2.startsWith("nf_adm_") ? "admin" : key2.startsWith("nf_ci_") ? "ci" : key2.startsWith("nf_srv_") ? "server" : key2.startsWith("nf_pub_") ? "client" : "unknown";
|
|
212
|
+
}
|
|
213
|
+
var Api = class {
|
|
214
|
+
constructor(o) {
|
|
215
|
+
this.o = o;
|
|
216
|
+
}
|
|
217
|
+
o;
|
|
218
|
+
async call(method, path2, body) {
|
|
219
|
+
let res;
|
|
220
|
+
try {
|
|
221
|
+
res = await this.o.fetch(`${this.o.baseUrl}${path2}`, {
|
|
222
|
+
method,
|
|
223
|
+
headers: { Authorization: `Bearer ${this.o.key}`, Accept: "application/json", "User-Agent": this.o.userAgent, ...body === void 0 ? {} : { "Content-Type": "application/json" } },
|
|
224
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
225
|
+
signal: AbortSignal.timeout(3e4)
|
|
226
|
+
});
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw new CliError(`Could not reach ${this.o.baseUrl}: ${e instanceof Error ? e.message : String(e)}`, EXIT.unavailable);
|
|
229
|
+
}
|
|
230
|
+
const text = await res.text();
|
|
231
|
+
let json = null;
|
|
232
|
+
try {
|
|
233
|
+
json = text === "" ? null : JSON.parse(text);
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
if (res.ok) {
|
|
237
|
+
if (json === null) throw new CliError(`${method} ${path2} returned an empty or non-JSON response (HTTP ${res.status}).`, EXIT.unavailable);
|
|
238
|
+
return json;
|
|
239
|
+
}
|
|
240
|
+
const err = json?.error;
|
|
241
|
+
const code = typeof err === "object" && err !== null && typeof err.code === "string" ? err.code : typeof err === "string" ? err : `http_${res.status}`;
|
|
242
|
+
const message = typeof err === "object" && err !== null && typeof err.message === "string" ? err.message : `HTTP ${res.status}`;
|
|
243
|
+
throw new ApiError(message, res.status, code);
|
|
244
|
+
}
|
|
245
|
+
export() {
|
|
246
|
+
return this.call("GET", "/v1/admin/export");
|
|
247
|
+
}
|
|
248
|
+
bundle() {
|
|
249
|
+
return this.call("GET", "/v1/bundle");
|
|
250
|
+
}
|
|
251
|
+
/** The published revision of the key's environment (CI or admin key). */
|
|
252
|
+
published() {
|
|
253
|
+
return this.call("GET", "/v1/ci/published");
|
|
254
|
+
}
|
|
255
|
+
runScenarios(body) {
|
|
256
|
+
return this.call("POST", "/v1/ci/scenarios/run", body);
|
|
257
|
+
}
|
|
258
|
+
publish(body) {
|
|
259
|
+
return this.call("POST", "/v1/admin/publish", body);
|
|
260
|
+
}
|
|
261
|
+
/** Creates a flag in the key's project (admin key). Every environment's draft starts with `default`. */
|
|
262
|
+
createFlag(body) {
|
|
263
|
+
return this.call("POST", "/v1/admin/flags", body);
|
|
264
|
+
}
|
|
265
|
+
putCodeRefs(body) {
|
|
266
|
+
return this.call("PUT", "/v1/ci/code-refs", body);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
function explain(e, key2, what) {
|
|
270
|
+
if (e instanceof CliError) return e;
|
|
271
|
+
if (e instanceof ApiError) {
|
|
272
|
+
const kind = keyKind(key2);
|
|
273
|
+
if (e.status === 401) return new CliError(`The key was rejected (unknown or revoked). Check NANOFLAGS_KEY.`, EXIT.usage);
|
|
274
|
+
if (e.status === 403) return new CliError(`This key may not ${what}${kind === "unknown" ? "" : ` (it is a ${kind} key)`}. ${e.message}`, EXIT.usage);
|
|
275
|
+
if (e.status === 404 && e.code === "not_found") return new CliError(`The edge at this base URL does not offer this endpoint (${e.message}). Check --base-url, or the edge is older than this CLI.`, EXIT.unavailable);
|
|
276
|
+
if (e.status >= 500) return new CliError(`NanoFlags could not ${what} right now (HTTP ${e.status}: ${e.message}).`, EXIT.unavailable);
|
|
277
|
+
return new CliError(`Could not ${what}: ${e.message} [${e.code}]`, EXIT.failed);
|
|
278
|
+
}
|
|
279
|
+
return new CliError(e instanceof Error ? e.message : String(e), EXIT.failed);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/main.ts
|
|
283
|
+
var VERSION = "0.2.0";
|
|
284
|
+
var HELP = `nanoflags ${VERSION} \u2014 NanoFlags command line
|
|
285
|
+
|
|
286
|
+
Usage: nanoflags <command> [options]
|
|
287
|
+
|
|
288
|
+
Commands
|
|
289
|
+
export [--out <file>] Download flags, segments and drafts as JSON
|
|
290
|
+
flags list [--json] List the project's flags
|
|
291
|
+
flags create <key> --type boolean|string|number|json [--default <json>] [--description <text>] [--name <text>] [--json]
|
|
292
|
+
Create a flag in the key's project (admin key); nothing is published
|
|
293
|
+
eval <flag> [--context <json>] [--source draft|published] [--fallback <json>] [--json]
|
|
294
|
+
Evaluate one flag locally and print value, reason and trace
|
|
295
|
+
scenarios run [--source draft|revision] [--commit <sha>] [--run-url <url>] [--json]
|
|
296
|
+
Run the saved scenarios; exit 1 when one fails
|
|
297
|
+
publish --expected-revision <n> [--note <text>]
|
|
298
|
+
Publish the draft of the key's environment (admin key)
|
|
299
|
+
refs scan [dir] --repo <owner/name> [--commit <sha>] [--dry-run] [--json]
|
|
300
|
+
Find flag references in source files and upload them
|
|
301
|
+
cleanup <flag> --value <json> [dir] [--write] Propose (or apply) the removal of a resolved flag from code
|
|
302
|
+
|
|
303
|
+
Global options
|
|
304
|
+
--key <key> CI or admin key. Default: $NANOFLAGS_KEY. Prefer the environment variable:
|
|
305
|
+
command-line arguments are visible to other processes.
|
|
306
|
+
--sdk-key <key> Optional server SDK key for \`eval --source published\` (reads /v1/bundle instead of
|
|
307
|
+
/v1/ci/published with --key). Default: $NANOFLAGS_SDK_KEY
|
|
308
|
+
--base-url <url> Default: $NANOFLAGS_BASE_URL or ${DEFAULT_BASE_URL}
|
|
309
|
+
--export <file> Read a saved \`nanoflags export\` instead of calling the API (eval, flags list, refs scan, cleanup)
|
|
310
|
+
-h, --help -v, --version
|
|
311
|
+
|
|
312
|
+
Exit codes: 0 ok \xB7 1 a check failed (failing scenario, publish refused) \xB7 2 usage, configuration or key problem \xB7 3 NanoFlags unreachable
|
|
313
|
+
`;
|
|
314
|
+
var OPTIONS = {
|
|
315
|
+
key: { type: "string" },
|
|
316
|
+
"sdk-key": { type: "string" },
|
|
317
|
+
"base-url": { type: "string" },
|
|
318
|
+
out: { type: "string" },
|
|
319
|
+
context: { type: "string" },
|
|
320
|
+
source: { type: "string" },
|
|
321
|
+
fallback: { type: "string" },
|
|
322
|
+
environment: { type: "string" },
|
|
323
|
+
commit: { type: "string" },
|
|
324
|
+
"run-url": { type: "string" },
|
|
325
|
+
json: { type: "boolean" },
|
|
326
|
+
"expected-revision": { type: "string" },
|
|
327
|
+
type: { type: "string" },
|
|
328
|
+
default: { type: "string" },
|
|
329
|
+
description: { type: "string" },
|
|
330
|
+
name: { type: "string" },
|
|
331
|
+
note: { type: "string" },
|
|
332
|
+
repo: { type: "string" },
|
|
333
|
+
"dry-run": { type: "boolean" },
|
|
334
|
+
value: { type: "string" },
|
|
335
|
+
write: { type: "boolean" },
|
|
336
|
+
export: { type: "string" },
|
|
337
|
+
"max-file-size": { type: "string" },
|
|
338
|
+
help: { type: "boolean", short: "h" },
|
|
339
|
+
version: { type: "boolean", short: "v" }
|
|
340
|
+
};
|
|
341
|
+
function usage(message) {
|
|
342
|
+
return new CliError(`${message}
|
|
343
|
+
Run \`nanoflags --help\` for usage.`, EXIT.usage);
|
|
344
|
+
}
|
|
345
|
+
function parseJson(text, what) {
|
|
346
|
+
try {
|
|
347
|
+
return JSON.parse(text);
|
|
348
|
+
} catch {
|
|
349
|
+
throw usage(`${what} must be valid JSON, got: ${clip(text, 80)}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function baseUrl(r) {
|
|
353
|
+
const raw = (r.values["base-url"] || r.io.env.NANOFLAGS_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
354
|
+
let url;
|
|
355
|
+
try {
|
|
356
|
+
url = new URL(raw);
|
|
357
|
+
} catch {
|
|
358
|
+
throw usage(`--base-url is not a URL: ${raw}`);
|
|
359
|
+
}
|
|
360
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
361
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw usage(`--base-url must use https (http is only accepted for localhost): ${raw}`);
|
|
362
|
+
return raw;
|
|
363
|
+
}
|
|
364
|
+
function key(r) {
|
|
365
|
+
const k = (r.values.key ?? r.io.env.NANOFLAGS_KEY ?? "").trim();
|
|
366
|
+
if (k === "") throw usage("No key. Set NANOFLAGS_KEY (or pass --key) to a NanoFlags CI or admin key.");
|
|
367
|
+
return k;
|
|
368
|
+
}
|
|
369
|
+
function api(r) {
|
|
370
|
+
return new Api({ baseUrl: baseUrl(r), key: key(r), fetch: r.io.fetch, userAgent: `nanoflags-cli/${VERSION}` });
|
|
371
|
+
}
|
|
372
|
+
function path(r, p) {
|
|
373
|
+
return isAbsolute(p) ? p : resolve(r.io.cwd, p);
|
|
374
|
+
}
|
|
375
|
+
async function loadExport(r) {
|
|
376
|
+
const file = r.values.export;
|
|
377
|
+
if (file !== void 0) {
|
|
378
|
+
let parsed;
|
|
379
|
+
try {
|
|
380
|
+
parsed = JSON.parse(readFileSync(path(r, file), "utf8"));
|
|
381
|
+
} catch (e) {
|
|
382
|
+
throw usage(`Could not read --export ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
383
|
+
}
|
|
384
|
+
if (!isExport(parsed)) throw usage(`--export ${file} is not a NanoFlags export.`);
|
|
385
|
+
return parsed;
|
|
386
|
+
}
|
|
387
|
+
const k = key(r);
|
|
388
|
+
try {
|
|
389
|
+
const data = await api(r).export();
|
|
390
|
+
if (!isExport(data)) throw new CliError("The export has an unexpected shape.", EXIT.unavailable);
|
|
391
|
+
return data;
|
|
392
|
+
} catch (e) {
|
|
393
|
+
throw explain(e, k, "read the project export");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function cmdExport(r) {
|
|
397
|
+
const data = await loadExport(r);
|
|
398
|
+
const text = `${JSON.stringify(data, null, 2)}
|
|
399
|
+
`;
|
|
400
|
+
if (r.values.out !== void 0) {
|
|
401
|
+
writeFileSync(path(r, r.values.out), text);
|
|
402
|
+
r.io.err(`Wrote ${r.values.out} (${data.flags.length} flags, ${data.segments?.length ?? 0} segments, ${Object.keys(data.drafts ?? {}).length} environments).
|
|
403
|
+
`);
|
|
404
|
+
} else r.io.out(text);
|
|
405
|
+
return EXIT.ok;
|
|
406
|
+
}
|
|
407
|
+
async function cmdFlagsList(r) {
|
|
408
|
+
const data = await loadExport(r);
|
|
409
|
+
if (r.values.json) {
|
|
410
|
+
r.io.out(`${JSON.stringify(data.flags, null, 2)}
|
|
411
|
+
`);
|
|
412
|
+
return EXIT.ok;
|
|
413
|
+
}
|
|
414
|
+
const draft = data.drafts?.[data.key_environment]?.flags ?? {};
|
|
415
|
+
const rows = data.flags.map((f) => {
|
|
416
|
+
const cfg = Object.prototype.hasOwnProperty.call(draft, f.key) ? draft[f.key] : void 0;
|
|
417
|
+
return [f.key, f.type, f.purpose ?? "", f.permanent ? "permanent" : "temporary", f.client_exposed ? "yes" : "no", f.archived ? "archived" : cfg === void 0 ? "-" : cfg.enabled ? "on" : "off"];
|
|
418
|
+
});
|
|
419
|
+
r.io.out(`${data.project.key} \xB7 ${data.flags.length} flags \xB7 state shown for ${data.key_environment} (draft)
|
|
420
|
+
|
|
421
|
+
`);
|
|
422
|
+
r.io.out(`${table(["KEY", "TYPE", "PURPOSE", "LIFETIME", "CLIENT", "STATE"], rows)}
|
|
423
|
+
`);
|
|
424
|
+
return EXIT.ok;
|
|
425
|
+
}
|
|
426
|
+
var FLAG_KEY = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
427
|
+
var FLAG_TYPES = ["boolean", "string", "number", "json"];
|
|
428
|
+
function parseDefault(type, raw) {
|
|
429
|
+
if (raw === void 0) {
|
|
430
|
+
if (type === "boolean") return false;
|
|
431
|
+
throw usage(`flags create needs --default for a ${type} flag: the value every environment's draft starts with (JSON, e.g. ${type === "string" ? `'"blue"'` : type === "number" ? "0" : "'{}'"}).`);
|
|
432
|
+
}
|
|
433
|
+
let value;
|
|
434
|
+
try {
|
|
435
|
+
value = JSON.parse(raw);
|
|
436
|
+
} catch {
|
|
437
|
+
if (type !== "string") throw usage(`--default must be valid JSON for a ${type} flag, got: ${clip(raw, 80)}`);
|
|
438
|
+
value = raw;
|
|
439
|
+
}
|
|
440
|
+
const ok = type === "boolean" ? typeof value === "boolean" : type === "string" ? typeof value === "string" : type === "number" ? typeof value === "number" && Number.isFinite(value) : true;
|
|
441
|
+
if (!ok) throw usage(`--default must be a ${type} value for a ${type} flag, got: ${clip(raw, 80)}`);
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
var CREATE_HELP = {
|
|
445
|
+
CONFLICT: "Choose another key, or use the existing flag (`nanoflags flags list`).",
|
|
446
|
+
INVALID_VALUE: "The default does not match the flag type.",
|
|
447
|
+
INVALID: "Check the key, name and description."
|
|
448
|
+
};
|
|
449
|
+
async function cmdFlagsCreate(r) {
|
|
450
|
+
const flagKey = r.args[0];
|
|
451
|
+
if (!flagKey || r.args.length > 1) throw usage("Usage: nanoflags flags create <key> --type boolean|string|number|json [--default <json>] [--description <text>] [--name <text>]");
|
|
452
|
+
if (!FLAG_KEY.test(flagKey)) throw usage(`The flag key must start with a lowercase letter or digit and use only lowercase letters, digits, hyphens and underscores (max 63), got "${clip(flagKey, 80)}".`);
|
|
453
|
+
const type = r.values.type ?? "boolean";
|
|
454
|
+
if (!FLAG_TYPES.includes(type)) throw usage(`--type must be one of ${FLAG_TYPES.join(", ")}, got "${type}".`);
|
|
455
|
+
const value = parseDefault(type, r.values.default);
|
|
456
|
+
const description = r.values.description ?? "";
|
|
457
|
+
if (description.length > 2e3) throw usage("--description is limited to 2000 characters.");
|
|
458
|
+
const name = r.values.name;
|
|
459
|
+
if (name !== void 0 && (name.trim() === "" || name.length > 120)) throw usage("--name must be 1\u2013120 characters.");
|
|
460
|
+
const k = key(r);
|
|
461
|
+
const kind = keyKind(k);
|
|
462
|
+
if (kind !== "admin" && kind !== "unknown") throw usage(`flags create needs an admin key; this is a ${kind} key.`);
|
|
463
|
+
try {
|
|
464
|
+
const res = await api(r).createFlag({ key: flagKey, type, default: value, description, ...name !== void 0 ? { name: name.trim() } : {} });
|
|
465
|
+
if (r.values.json) r.io.out(`${JSON.stringify(res, null, 2)}
|
|
466
|
+
`);
|
|
467
|
+
else {
|
|
468
|
+
r.io.out(`Created ${res.key} (${res.type}, default ${JSON.stringify(res.default ?? value)}).
|
|
469
|
+
`);
|
|
470
|
+
r.io.out(`Every environment's draft now has it${Array.isArray(res.environments) && res.environments.length ? ` (${res.environments.join(", ")})` : ""}. Nothing was published.
|
|
471
|
+
`);
|
|
472
|
+
}
|
|
473
|
+
return EXIT.ok;
|
|
474
|
+
} catch (e) {
|
|
475
|
+
const err = e;
|
|
476
|
+
const help = e instanceof ApiError && typeof err.code === "string" ? CREATE_HELP[err.code] : void 0;
|
|
477
|
+
if (help) {
|
|
478
|
+
r.io.err(`Flag not created: ${err.message ?? err.code} [${err.code}]
|
|
479
|
+
${help}
|
|
480
|
+
`);
|
|
481
|
+
return EXIT.failed;
|
|
482
|
+
}
|
|
483
|
+
throw explain(e, k, "create flags");
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function isBundle(v) {
|
|
487
|
+
if (typeof v !== "object" || v === null) return false;
|
|
488
|
+
const b = v;
|
|
489
|
+
return typeof b.flags === "object" && b.flags !== null && typeof b.segments === "object" && b.segments !== null;
|
|
490
|
+
}
|
|
491
|
+
async function cmdEval(r) {
|
|
492
|
+
const flag = r.args[0];
|
|
493
|
+
if (!flag || r.args.length > 1) throw usage(`Usage: nanoflags eval <flag> [--context '{"key":"u1"}'] [--source draft|published]`);
|
|
494
|
+
const source = r.values.source ?? "draft";
|
|
495
|
+
if (source !== "draft" && source !== "published") throw usage(`--source must be draft or published, got "${source}".`);
|
|
496
|
+
const ctx = r.values.context === void 0 ? {} : parseJson(r.values.context, "--context");
|
|
497
|
+
if (typeof ctx !== "object" || ctx === null || Array.isArray(ctx)) throw usage("--context must be a JSON object.");
|
|
498
|
+
const fallback = r.values.fallback === void 0 ? null : parseJson(r.values.fallback, "--fallback");
|
|
499
|
+
let bundle;
|
|
500
|
+
let label;
|
|
501
|
+
if (source === "draft") {
|
|
502
|
+
const data = await loadExport(r);
|
|
503
|
+
const env = r.values.environment ?? data.key_environment;
|
|
504
|
+
const draft = data.drafts?.[env];
|
|
505
|
+
if (!isBundle(draft)) throw usage(`The export has no draft for environment "${env}". Available: ${Object.keys(data.drafts ?? {}).join(", ") || "none"}.`);
|
|
506
|
+
bundle = { schema: 1, project: data.project.key, environment: env, revision: 0, flags: draft.flags, segments: draft.segments };
|
|
507
|
+
label = `draft of ${env}`;
|
|
508
|
+
} else {
|
|
509
|
+
if (r.values.environment !== void 0) throw usage("--environment only applies to --source draft: a key reads the published configuration of its own environment.");
|
|
510
|
+
const sdkKey = (r.values["sdk-key"] ?? r.io.env.NANOFLAGS_SDK_KEY ?? "").trim();
|
|
511
|
+
const main2 = (r.values.key ?? r.io.env.NANOFLAGS_KEY ?? "").trim();
|
|
512
|
+
const k = sdkKey !== "" ? sdkKey : keyKind(main2) === "server" || keyKind(main2) === "client" ? main2 : "";
|
|
513
|
+
if (k === "" && main2 === "") throw usage("--source published needs a key: NANOFLAGS_KEY (a CI or admin key of the environment) or NANOFLAGS_SDK_KEY (--sdk-key, a server SDK key).");
|
|
514
|
+
const useSdk = k !== "";
|
|
515
|
+
const used = useSdk ? k : main2;
|
|
516
|
+
try {
|
|
517
|
+
const client = new Api({ baseUrl: baseUrl(r), key: used, fetch: r.io.fetch, userAgent: `nanoflags-cli/${VERSION}` });
|
|
518
|
+
const res = useSdk ? await client.bundle() : await client.published();
|
|
519
|
+
if (!isBundle(res.bundle)) throw new CliError("The published bundle has an unexpected shape.", EXIT.unavailable);
|
|
520
|
+
bundle = res.bundle;
|
|
521
|
+
label = `published r${res.revision}`;
|
|
522
|
+
} catch (e) {
|
|
523
|
+
if (!useSdk && e instanceof ApiError && e.code === "INVALID_STATE") throw new CliError(`Nothing is published in this key's environment yet. ${e.message}`, EXIT.usage);
|
|
524
|
+
throw explain(e, used, "read the published configuration");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const result = evaluate(bundle, flag, ctx, fallback);
|
|
528
|
+
if (r.values.json) r.io.out(`${JSON.stringify({ source: label, ...result }, null, 2)}
|
|
529
|
+
`);
|
|
530
|
+
else {
|
|
531
|
+
const rule = result.ruleId !== void 0 ? ` \xB7 rule ${result.ruleId} (#${(result.ruleIndex ?? 0) + 1})` : "";
|
|
532
|
+
const rollout = result.inRollout === void 0 ? "" : ` \xB7 ${result.inRollout ? "inside" : "outside"} rollout${result.bucket === void 0 ? "" : ` (bucket ${result.bucket.toFixed(2)})`}`;
|
|
533
|
+
r.io.out(`${flag} = ${JSON.stringify(result.value)}
|
|
534
|
+
`);
|
|
535
|
+
r.io.out(`reason: ${result.reason}${rule}${rollout}
|
|
536
|
+
`);
|
|
537
|
+
r.io.out(`source: ${label}
|
|
538
|
+
`);
|
|
539
|
+
r.io.out(`trace:
|
|
540
|
+
${result.trace.map((t) => ` ${t.kind.padEnd(10)} ${t.title} \u2014 ${t.detail}`).join("\n")}
|
|
541
|
+
`);
|
|
542
|
+
if (result.reason === "flag_not_found") r.io.err(`Note: "${flag}" is not in this configuration; the value above is the fallback.
|
|
543
|
+
`);
|
|
544
|
+
}
|
|
545
|
+
return EXIT.ok;
|
|
546
|
+
}
|
|
547
|
+
var cell = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
548
|
+
function scenariosMarkdown(run, meta) {
|
|
549
|
+
const lines = [
|
|
550
|
+
`### NanoFlags scenarios: ${run.failed === 0 ? "passed" : "FAILED"}`,
|
|
551
|
+
"",
|
|
552
|
+
`**${run.passed} passed, ${run.failed} failed** \xB7 source \`${run.source}\`${run.checksum ? ` \xB7 checksum \`${run.checksum}\`` : ""}${run.publishedRevision != null ? ` \xB7 published r${run.publishedRevision}` : ""}${meta.commit ? ` \xB7 commit \`${meta.commit.slice(0, 12)}\`` : ""}`,
|
|
553
|
+
""
|
|
554
|
+
];
|
|
555
|
+
if (run.results.length === 0) lines.push("No scenarios are included in CI for this project.");
|
|
556
|
+
else {
|
|
557
|
+
lines.push("| Result | Scenario | Flag | Expected | Actual | Reason |", "| --- | --- | --- | --- | --- | --- |");
|
|
558
|
+
for (const s of run.results) lines.push(`| ${s.passed ? "pass" : "**FAIL**"} | ${cell(s.name)} | \`${cell(s.flag)}\` | \`${cell(show(s.expected))}\` | \`${cell(show(s.actual))}\` | ${cell(s.reason ?? "")} |`);
|
|
559
|
+
}
|
|
560
|
+
return `${lines.join("\n")}
|
|
561
|
+
|
|
562
|
+
`;
|
|
563
|
+
}
|
|
564
|
+
async function cmdScenariosRun(r) {
|
|
565
|
+
const source = r.values.source ?? "draft";
|
|
566
|
+
if (source !== "draft" && source !== "revision") throw usage(`--source must be draft or revision, got "${source}".`);
|
|
567
|
+
const commit = r.values.commit || r.io.env.GITHUB_SHA;
|
|
568
|
+
const runUrl = r.values["run-url"] || (r.io.env.GITHUB_SERVER_URL && r.io.env.GITHUB_REPOSITORY && r.io.env.GITHUB_RUN_ID ? `${r.io.env.GITHUB_SERVER_URL}/${r.io.env.GITHUB_REPOSITORY}/actions/runs/${r.io.env.GITHUB_RUN_ID}` : void 0);
|
|
569
|
+
const k = key(r);
|
|
570
|
+
let run;
|
|
571
|
+
try {
|
|
572
|
+
run = await api(r).runScenarios({ source, ...commit ? { commit } : {}, ...runUrl ? { runUrl } : {} });
|
|
573
|
+
} catch (e) {
|
|
574
|
+
throw explain(e, k, "run scenarios");
|
|
575
|
+
}
|
|
576
|
+
if (!run || !Array.isArray(run.results)) throw new CliError("The scenario run has an unexpected shape.", EXIT.unavailable);
|
|
577
|
+
const failed = Math.max(Number(run.failed) || 0, run.results.filter((s) => !s.passed).length);
|
|
578
|
+
const passed = run.results.filter((s) => s.passed).length;
|
|
579
|
+
const normalized = { ...run, passed, failed };
|
|
580
|
+
if (r.values.json) r.io.out(`${JSON.stringify(normalized, null, 2)}
|
|
581
|
+
`);
|
|
582
|
+
else {
|
|
583
|
+
if (run.results.length === 0) r.io.out("No scenarios are included in CI for this project.\n");
|
|
584
|
+
else r.io.out(`${table(["RESULT", "SCENARIO", "FLAG", "EXPECTED", "ACTUAL", "REASON"], run.results.map((s) => [s.passed ? "pass" : "FAIL", clip(s.name, 48), s.flag, clip(show(s.expected), 32), clip(show(s.actual), 32), s.reason ?? ""]))}
|
|
585
|
+
`);
|
|
586
|
+
r.io.out(`
|
|
587
|
+
${passed} passed, ${failed} failed \xB7 source ${run.source}${run.checksum ? ` \xB7 checksum ${run.checksum}` : ""}${run.publishedRevision != null ? ` \xB7 published r${run.publishedRevision}` : ""}
|
|
588
|
+
`);
|
|
589
|
+
}
|
|
590
|
+
const summary = r.io.env.GITHUB_STEP_SUMMARY;
|
|
591
|
+
if (summary) {
|
|
592
|
+
try {
|
|
593
|
+
appendFileSync(summary, scenariosMarkdown(normalized, { ...commit ? { commit } : {}, ...runUrl ? { runUrl } : {} }));
|
|
594
|
+
} catch (e) {
|
|
595
|
+
r.io.err(`Could not write the job summary: ${e instanceof Error ? e.message : String(e)}
|
|
596
|
+
`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (failed > 0) {
|
|
600
|
+
r.io.err(`${failed} scenario${failed === 1 ? "" : "s"} failed.
|
|
601
|
+
`);
|
|
602
|
+
return EXIT.failed;
|
|
603
|
+
}
|
|
604
|
+
return EXIT.ok;
|
|
605
|
+
}
|
|
606
|
+
var PUBLISH_HELP = {
|
|
607
|
+
DESTINATION_CHANGED: "Someone published in the meantime. Review the new revision, then run again with its number as --expected-revision.",
|
|
608
|
+
CHANGE_REQUIRED: "This environment only accepts reviewed changes. Publish it from the dashboard; an admin key cannot bypass review.",
|
|
609
|
+
SCENARIOS_REQUIRED: "Run `nanoflags scenarios run --source draft` and fix the failing scenarios (or the draft), then publish again.",
|
|
610
|
+
NO_CHANGES: "There is nothing to publish."
|
|
611
|
+
};
|
|
612
|
+
async function cmdPublish(r) {
|
|
613
|
+
const raw = r.values["expected-revision"];
|
|
614
|
+
if (raw === void 0 || !/^\d+$/.test(raw)) throw usage("publish needs --expected-revision <n>: the revision you believe is currently published (see `nanoflags export`). It prevents publishing over someone else's change.");
|
|
615
|
+
const k = key(r);
|
|
616
|
+
const kind = keyKind(k);
|
|
617
|
+
if (kind !== "admin" && kind !== "unknown") throw usage(`publish needs an admin key; this is a ${kind} key. ${kind === "ci" ? "CI keys can run scenarios and upload references, never publish." : ""}`.trim());
|
|
618
|
+
try {
|
|
619
|
+
const res = await api(r).publish({ expectedRevision: Number(raw), ...r.values.note ? { note: r.values.note } : {} });
|
|
620
|
+
if (r.values.json) r.io.out(`${JSON.stringify(res, null, 2)}
|
|
621
|
+
`);
|
|
622
|
+
else r.io.out(`Published r${res.revision} (checksum ${res.checksum}).
|
|
623
|
+
`);
|
|
624
|
+
return EXIT.ok;
|
|
625
|
+
} catch (e) {
|
|
626
|
+
const err = e;
|
|
627
|
+
const help = typeof err.code === "string" ? PUBLISH_HELP[err.code] : void 0;
|
|
628
|
+
if (help) {
|
|
629
|
+
r.io.err(`Publish refused: ${err.message ?? err.code} [${err.code}]
|
|
630
|
+
${help}
|
|
631
|
+
`);
|
|
632
|
+
return EXIT.failed;
|
|
633
|
+
}
|
|
634
|
+
throw explain(e, k, "publish");
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function directory(r, arg) {
|
|
638
|
+
const dir = path(r, arg ?? ".");
|
|
639
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw usage(`Not a directory: ${arg ?? "."}`);
|
|
640
|
+
return dir;
|
|
641
|
+
}
|
|
642
|
+
function maxBytes(r) {
|
|
643
|
+
const raw = r.values["max-file-size"];
|
|
644
|
+
if (raw === void 0) return DEFAULT_MAX_FILE_BYTES;
|
|
645
|
+
if (!/^\d+$/.test(raw) || Number(raw) <= 0) throw usage("--max-file-size must be a positive number of bytes.");
|
|
646
|
+
return Number(raw);
|
|
647
|
+
}
|
|
648
|
+
var MAX_REFS = 5e3;
|
|
649
|
+
async function cmdRefsScan(r) {
|
|
650
|
+
if (r.args.length > 1) throw usage("Usage: nanoflags refs scan [dir] --repo <owner/name> [--commit <sha>] [--dry-run]");
|
|
651
|
+
const dry = r.values["dry-run"] === true;
|
|
652
|
+
const repo = r.values.repo || r.io.env.GITHUB_REPOSITORY || void 0;
|
|
653
|
+
if (!dry && !repo) throw usage("refs scan needs --repo <owner/name> (or run with --dry-run).");
|
|
654
|
+
if (repo !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(repo)) throw usage(`--repo must look like owner/name, got "${repo}".`);
|
|
655
|
+
const commit = r.values.commit || r.io.env.GITHUB_SHA || null;
|
|
656
|
+
const dir = directory(r, r.args[0]);
|
|
657
|
+
const data = await loadExport(r);
|
|
658
|
+
const keys = new Set(data.flags.map((f) => f.key));
|
|
659
|
+
const { scanText } = await import("./refs-CYG6GHJL.js");
|
|
660
|
+
const { files, tooLarge } = walk(dir, maxBytes(r));
|
|
661
|
+
const refs = [];
|
|
662
|
+
for (const file of files) {
|
|
663
|
+
let text;
|
|
664
|
+
try {
|
|
665
|
+
text = readFileSync(join(dir, file), "utf8");
|
|
666
|
+
} catch {
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
refs.push(...scanText(file, text, keys));
|
|
670
|
+
}
|
|
671
|
+
for (const f of tooLarge) r.io.err(`Skipped ${f}: larger than the file size cap.
|
|
672
|
+
`);
|
|
673
|
+
const truncated = refs.length > MAX_REFS;
|
|
674
|
+
const upload = truncated ? refs.slice(0, MAX_REFS) : refs;
|
|
675
|
+
if (truncated) r.io.err(`Found ${refs.length} references; only the first ${MAX_REFS} are uploaded.
|
|
676
|
+
`);
|
|
677
|
+
const flagsSeen = new Set(refs.map((x) => x.flag));
|
|
678
|
+
if (r.values.json) r.io.out(`${JSON.stringify({ repo: repo ?? null, commit, files: files.length, refs: upload }, null, 2)}
|
|
679
|
+
`);
|
|
680
|
+
else {
|
|
681
|
+
if (refs.length > 0) r.io.out(`${table(["FLAG", "LOCATION", "CODE"], refs.map((x) => [x.flag, `${x.path}:${x.line}`, clip(x.snippet, 80)]))}
|
|
682
|
+
|
|
683
|
+
`);
|
|
684
|
+
r.io.out(`${refs.length} reference${refs.length === 1 ? "" : "s"} to ${flagsSeen.size} of ${keys.size} flags in ${files.length} files.
|
|
685
|
+
`);
|
|
686
|
+
}
|
|
687
|
+
if (dry) {
|
|
688
|
+
r.io.err("Dry run: nothing was uploaded.\n");
|
|
689
|
+
return EXIT.ok;
|
|
690
|
+
}
|
|
691
|
+
const k = key(r);
|
|
692
|
+
try {
|
|
693
|
+
const res = await api(r).putCodeRefs({ repo, commit, refs: upload });
|
|
694
|
+
r.io.err(`Uploaded: ${res.stored} reference${res.stored === 1 ? "" : "s"} stored for ${repo}${commit ? ` at ${commit.slice(0, 12)}` : ""}. Earlier references for this repository were replaced.
|
|
695
|
+
`);
|
|
696
|
+
} catch (e) {
|
|
697
|
+
throw explain(e, k, "upload code references");
|
|
698
|
+
}
|
|
699
|
+
return EXIT.ok;
|
|
700
|
+
}
|
|
701
|
+
function matchesType(type, v) {
|
|
702
|
+
if (type === "boolean") return typeof v === "boolean";
|
|
703
|
+
if (type === "string") return typeof v === "string";
|
|
704
|
+
if (type === "number") return typeof v === "number";
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
async function cmdCleanup(r) {
|
|
708
|
+
const flag = r.args[0];
|
|
709
|
+
if (!flag || r.args.length > 2) throw usage("Usage: nanoflags cleanup <flag> --value <json> [dir] [--write]");
|
|
710
|
+
if (r.values.value === void 0) throw usage(`cleanup needs --value <json>: the value the flag is permanently resolved to, e.g. --value true or --value '"v2"'.`);
|
|
711
|
+
const value = parseJson(r.values.value, "--value");
|
|
712
|
+
const dir = directory(r, r.args[1]);
|
|
713
|
+
const data = await loadExport(r);
|
|
714
|
+
const meta = data.flags.find((f) => f.key === flag);
|
|
715
|
+
if (!meta) throw usage(`Unknown flag "${flag}" in project ${data.project.key}.`);
|
|
716
|
+
if (meta.permanent) {
|
|
717
|
+
throw new CliError(`"${flag}" is marked permanent (${meta.purpose ?? "long-lived"}): it is meant to stay in code. Nothing was changed.
|
|
718
|
+
If it really should go, clear "permanent" on the flag in the dashboard first.`, EXIT.usage);
|
|
719
|
+
}
|
|
720
|
+
if (!matchesType(meta.type, value)) throw usage(`"${flag}" is a ${meta.type} flag; --value ${r.values.value} is not a ${meta.type}.`);
|
|
721
|
+
const write = r.values.write === true;
|
|
722
|
+
const { cleanupText } = await import("./cleanup-QYR3DM6S.js");
|
|
723
|
+
const { files, tooLarge } = walk(dir, maxBytes(r));
|
|
724
|
+
const transformed = [];
|
|
725
|
+
const skipped = [];
|
|
726
|
+
const unreachable = [];
|
|
727
|
+
let changedFiles = 0;
|
|
728
|
+
for (const file of files) {
|
|
729
|
+
const full = join(dir, file);
|
|
730
|
+
let text;
|
|
731
|
+
try {
|
|
732
|
+
text = readFileSync(full, "utf8");
|
|
733
|
+
} catch {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (!text.includes(flag)) continue;
|
|
737
|
+
const res = cleanupText(file, text, flag, value);
|
|
738
|
+
transformed.push(...res.transformed.map((s) => ({ ...s, path: file })));
|
|
739
|
+
unreachable.push(...res.unreachable.map((s) => ({ ...s, path: file })));
|
|
740
|
+
skipped.push(...res.skipped.map((s) => ({ ...s, path: file })));
|
|
741
|
+
if (res.output === text) continue;
|
|
742
|
+
changedFiles++;
|
|
743
|
+
if (write) writeFileSync(full, res.output);
|
|
744
|
+
else r.io.out(unifiedDiff(file, text, res.output));
|
|
745
|
+
}
|
|
746
|
+
for (const f of tooLarge) r.io.err(`Skipped ${f}: larger than the file size cap.
|
|
747
|
+
`);
|
|
748
|
+
const e = r.io.err;
|
|
749
|
+
e(`
|
|
750
|
+
${flag} \u2192 ${JSON.stringify(value)}
|
|
751
|
+
`);
|
|
752
|
+
e(`${write ? "Rewrote" : "Would rewrite"} ${transformed.length} reference${transformed.length === 1 ? "" : "s"} in ${changedFiles} file${changedFiles === 1 ? "" : "s"}.
|
|
753
|
+
`);
|
|
754
|
+
for (const s of transformed) e(` ${s.path}:${s.line} ${clip(s.snippet, 100)}
|
|
755
|
+
`);
|
|
756
|
+
if (skipped.length > 0) {
|
|
757
|
+
e(`
|
|
758
|
+
NOT transformed (${skipped.length}) \u2014 review these by hand:
|
|
759
|
+
`);
|
|
760
|
+
for (const s of skipped) e(` ${s.path}:${s.line} ${clip(s.snippet, 100)}
|
|
761
|
+
${s.reason}${s.removed ? " (it was inside code that this cleanup removes)" : ""}
|
|
762
|
+
`);
|
|
763
|
+
}
|
|
764
|
+
if (unreachable.length > 0) {
|
|
765
|
+
e(`
|
|
766
|
+
Now unreachable (${unreachable.length}) \u2014 code after a return/throw that the cleanup exposed; delete it if it is no longer needed (line numbers after the cleanup):
|
|
767
|
+
`);
|
|
768
|
+
for (const s of unreachable) e(` ${s.path}:${s.line} ${clip(s.snippet, 100)}
|
|
769
|
+
`);
|
|
770
|
+
}
|
|
771
|
+
if (transformed.length === 0 && skipped.length === 0) e("No references found. Only calls with the flag key as a string literal are recognised.\n");
|
|
772
|
+
e(
|
|
773
|
+
write ? "\nFiles were edited in place. Nothing was committed or pushed: review the changes (git diff), run your tests, then commit.\n" : "\nDry run: no file was changed. Re-run with --write to apply. This command never commits, pushes or opens pull requests.\n"
|
|
774
|
+
);
|
|
775
|
+
return EXIT.ok;
|
|
776
|
+
}
|
|
777
|
+
async function main(argv, io) {
|
|
778
|
+
let values;
|
|
779
|
+
let positionals;
|
|
780
|
+
try {
|
|
781
|
+
const parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
|
|
782
|
+
values = parsed.values;
|
|
783
|
+
positionals = parsed.positionals;
|
|
784
|
+
} catch (e) {
|
|
785
|
+
io.err(`${e instanceof Error ? e.message : String(e)}
|
|
786
|
+
Run \`nanoflags --help\` for usage.
|
|
787
|
+
`);
|
|
788
|
+
return EXIT.usage;
|
|
789
|
+
}
|
|
790
|
+
if (values.version) {
|
|
791
|
+
io.out(`${VERSION}
|
|
792
|
+
`);
|
|
793
|
+
return EXIT.ok;
|
|
794
|
+
}
|
|
795
|
+
const [command, sub] = positionals;
|
|
796
|
+
if (values.help || command === void 0 || command === "help") {
|
|
797
|
+
io.out(HELP);
|
|
798
|
+
return command === void 0 && !values.help ? EXIT.usage : EXIT.ok;
|
|
799
|
+
}
|
|
800
|
+
const run = (skip) => ({ io, values, args: positionals.slice(skip) });
|
|
801
|
+
try {
|
|
802
|
+
if (command === "export") return await cmdExport(run(1));
|
|
803
|
+
if (command === "flags" && sub === "list") return await cmdFlagsList(run(2));
|
|
804
|
+
if (command === "flags" && sub === "create") return await cmdFlagsCreate(run(2));
|
|
805
|
+
if (command === "eval") return await cmdEval(run(1));
|
|
806
|
+
if (command === "scenarios" && sub === "run") return await cmdScenariosRun(run(2));
|
|
807
|
+
if (command === "publish") return await cmdPublish(run(1));
|
|
808
|
+
if (command === "refs" && sub === "scan") return await cmdRefsScan(run(2));
|
|
809
|
+
if (command === "cleanup") return await cmdCleanup(run(1));
|
|
810
|
+
throw usage(`Unknown command: ${positionals.slice(0, 2).join(" ")}`);
|
|
811
|
+
} catch (e) {
|
|
812
|
+
if (e instanceof CliError) {
|
|
813
|
+
io.err(`${e.message}
|
|
814
|
+
`);
|
|
815
|
+
return e.exitCode;
|
|
816
|
+
}
|
|
817
|
+
io.err(`Unexpected error: ${e instanceof Error ? e.stack ?? e.message : String(e)}
|
|
818
|
+
`);
|
|
819
|
+
return EXIT.failed;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/bin.ts
|
|
824
|
+
main(process.argv.slice(2), {
|
|
825
|
+
env: process.env,
|
|
826
|
+
cwd: process.cwd(),
|
|
827
|
+
fetch: (input, init) => fetch(input, init),
|
|
828
|
+
out: (text) => void process.stdout.write(text),
|
|
829
|
+
err: (text) => void process.stderr.write(text)
|
|
830
|
+
}).then(
|
|
831
|
+
(code) => {
|
|
832
|
+
process.exitCode = code;
|
|
833
|
+
},
|
|
834
|
+
(e) => {
|
|
835
|
+
process.stderr.write(`${e instanceof Error ? e.message : String(e)}
|
|
836
|
+
`);
|
|
837
|
+
process.exitCode = 1;
|
|
838
|
+
}
|
|
839
|
+
);
|