@eide/foir-cli 0.16.0 → 0.18.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/dist/cli.js +472 -10
- package/dist/lib/config-helpers.d.ts +34 -1
- package/dist/lib/config-helpers.js +4 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -2083,6 +2083,7 @@ function createSegmentsMethods(client) {
|
|
|
2083
2083
|
|
|
2084
2084
|
// src/lib/rpc/experiments.ts
|
|
2085
2085
|
import { create as create7 } from "@bufbuild/protobuf";
|
|
2086
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
2086
2087
|
import {
|
|
2087
2088
|
ExperimentStatus,
|
|
2088
2089
|
CreateExperimentRequestSchema,
|
|
@@ -2096,6 +2097,7 @@ import {
|
|
|
2096
2097
|
ResumeExperimentRequestSchema,
|
|
2097
2098
|
EndExperimentRequestSchema,
|
|
2098
2099
|
GetExperimentStatsRequestSchema,
|
|
2100
|
+
ExperimentMetricSpecSchema,
|
|
2099
2101
|
ForceAssignExperimentRequestSchema,
|
|
2100
2102
|
RemoveExperimentAssignmentRequestSchema,
|
|
2101
2103
|
ApplyExperimentWinnerRequestSchema,
|
|
@@ -2105,6 +2107,7 @@ import {
|
|
|
2105
2107
|
ExperimentGoalSchema,
|
|
2106
2108
|
ExperimentFunnelStepSchema
|
|
2107
2109
|
} from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
|
|
2110
|
+
import { MetricAggregator as MetricAggregator2 } from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
|
|
2108
2111
|
import { ExperimentStatus as ExperimentStatus2 } from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
|
|
2109
2112
|
var STATUS_TO_PROTO = {
|
|
2110
2113
|
draft: ExperimentStatus.DRAFT,
|
|
@@ -2138,9 +2141,22 @@ function createExperimentsMethods(client) {
|
|
|
2138
2141
|
})
|
|
2139
2142
|
);
|
|
2140
2143
|
},
|
|
2141
|
-
async getExperimentStats(experimentId) {
|
|
2144
|
+
async getExperimentStats(experimentId, params = {}) {
|
|
2142
2145
|
const resp = await client.getExperimentStats(
|
|
2143
|
-
create7(GetExperimentStatsRequestSchema, {
|
|
2146
|
+
create7(GetExperimentStatsRequestSchema, {
|
|
2147
|
+
experimentId,
|
|
2148
|
+
from: params.from ? timestampFromDate(params.from) : void 0,
|
|
2149
|
+
to: params.to ? timestampFromDate(params.to) : void 0,
|
|
2150
|
+
metrics: params.metrics?.map(
|
|
2151
|
+
(m) => create7(ExperimentMetricSpecSchema, {
|
|
2152
|
+
name: m.name,
|
|
2153
|
+
path: m.path,
|
|
2154
|
+
aggregator: m.aggregator,
|
|
2155
|
+
goalKey: m.goalKey
|
|
2156
|
+
})
|
|
2157
|
+
) ?? [],
|
|
2158
|
+
minSampleSize: params.minSampleSize
|
|
2159
|
+
})
|
|
2144
2160
|
);
|
|
2145
2161
|
return resp.stats ?? null;
|
|
2146
2162
|
},
|
|
@@ -7240,7 +7256,8 @@ function registerSegmentsCommands(program2, globalOpts) {
|
|
|
7240
7256
|
import {
|
|
7241
7257
|
ExperimentSchema,
|
|
7242
7258
|
ExperimentStatsSchema,
|
|
7243
|
-
ExperimentAssignmentSchema
|
|
7259
|
+
ExperimentAssignmentSchema,
|
|
7260
|
+
MetricAggregator as MetricAggregator3
|
|
7244
7261
|
} from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
|
|
7245
7262
|
function registerExperimentsCommands(program2, globalOpts) {
|
|
7246
7263
|
const experiments = program2.command("experiments").description("Manage experiments");
|
|
@@ -7380,13 +7397,37 @@ function registerExperimentsCommands(program2, globalOpts) {
|
|
|
7380
7397
|
success("Experiment ended");
|
|
7381
7398
|
})
|
|
7382
7399
|
);
|
|
7383
|
-
experiments.command("stats <id>").description(
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7400
|
+
experiments.command("stats <id>").description(
|
|
7401
|
+
"Decision-grade experiment statistics: per-variant assignments, per-goal conversion + significance, and optional metric aggregations."
|
|
7402
|
+
).option("--from <iso8601>", "Lower bound on converted_at (defaults to experiment start)").option("--to <iso8601>", "Upper bound on converted_at (defaults to now)").option(
|
|
7403
|
+
"--metric <spec>",
|
|
7404
|
+
"Metric aggregation in name=path:aggregator[:goal_key] form (e.g. cart=cartValue:sum:checkout_complete). Repeatable.",
|
|
7405
|
+
collectMetric,
|
|
7406
|
+
[]
|
|
7407
|
+
).option("--min-sample-size <n>", "Per-arm sample-size gate for is_significant (default 100)", parseInt).action(
|
|
7408
|
+
withErrorHandler(
|
|
7409
|
+
globalOpts,
|
|
7410
|
+
async (id, cmdOpts) => {
|
|
7411
|
+
const opts = globalOpts();
|
|
7412
|
+
const client = await createPlatformClient(opts);
|
|
7413
|
+
const metrics = cmdOpts.metric.map(parseMetricSpec);
|
|
7414
|
+
const result = await client.experiments.getExperimentStats(id, {
|
|
7415
|
+
from: cmdOpts.from ? new Date(cmdOpts.from) : void 0,
|
|
7416
|
+
to: cmdOpts.to ? new Date(cmdOpts.to) : void 0,
|
|
7417
|
+
metrics,
|
|
7418
|
+
minSampleSize: cmdOpts.minSampleSize
|
|
7419
|
+
});
|
|
7420
|
+
if (opts.json || opts.jsonl) {
|
|
7421
|
+
formatOutputProto(ExperimentStatsSchema, result, opts);
|
|
7422
|
+
return;
|
|
7423
|
+
}
|
|
7424
|
+
if (!result) {
|
|
7425
|
+
console.log("No stats \u2014 experiment not found or no data in window.");
|
|
7426
|
+
return;
|
|
7427
|
+
}
|
|
7428
|
+
renderStatsTable(result);
|
|
7429
|
+
}
|
|
7430
|
+
)
|
|
7390
7431
|
);
|
|
7391
7432
|
experiments.command("force-assign <experimentId>").description(
|
|
7392
7433
|
"Force a known identity into a specific variant (admin override)."
|
|
@@ -7487,6 +7528,206 @@ function registerExperimentsCommands(program2, globalOpts) {
|
|
|
7487
7528
|
)
|
|
7488
7529
|
);
|
|
7489
7530
|
}
|
|
7531
|
+
function collectMetric(value, accumulator) {
|
|
7532
|
+
return [...accumulator, value];
|
|
7533
|
+
}
|
|
7534
|
+
function parseMetricSpec(raw) {
|
|
7535
|
+
const eq = raw.indexOf("=");
|
|
7536
|
+
if (eq <= 0) {
|
|
7537
|
+
throw new Error(
|
|
7538
|
+
`--metric ${JSON.stringify(raw)}: expected name=path:aggregator[:goal_key]`
|
|
7539
|
+
);
|
|
7540
|
+
}
|
|
7541
|
+
const name = raw.slice(0, eq);
|
|
7542
|
+
const rest = raw.slice(eq + 1);
|
|
7543
|
+
const parts = rest.split(":");
|
|
7544
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
7545
|
+
throw new Error(
|
|
7546
|
+
`--metric ${JSON.stringify(raw)}: expected path:aggregator[:goal_key] after =`
|
|
7547
|
+
);
|
|
7548
|
+
}
|
|
7549
|
+
const path3 = parts[0];
|
|
7550
|
+
const aggStr = parts[1];
|
|
7551
|
+
const goalKey = parts[2];
|
|
7552
|
+
if (!path3 || !aggStr) {
|
|
7553
|
+
throw new Error(
|
|
7554
|
+
`--metric ${JSON.stringify(raw)}: expected path:aggregator[:goal_key] after =`
|
|
7555
|
+
);
|
|
7556
|
+
}
|
|
7557
|
+
return {
|
|
7558
|
+
name,
|
|
7559
|
+
path: path3,
|
|
7560
|
+
aggregator: parseAggregator(aggStr),
|
|
7561
|
+
goalKey: goalKey || void 0
|
|
7562
|
+
};
|
|
7563
|
+
}
|
|
7564
|
+
function parseAggregator(s) {
|
|
7565
|
+
switch (s.toLowerCase()) {
|
|
7566
|
+
case "sum":
|
|
7567
|
+
return MetricAggregator3.SUM;
|
|
7568
|
+
case "avg":
|
|
7569
|
+
case "mean":
|
|
7570
|
+
return MetricAggregator3.AVG;
|
|
7571
|
+
case "count":
|
|
7572
|
+
return MetricAggregator3.COUNT;
|
|
7573
|
+
case "min":
|
|
7574
|
+
return MetricAggregator3.MIN;
|
|
7575
|
+
case "max":
|
|
7576
|
+
return MetricAggregator3.MAX;
|
|
7577
|
+
case "p50":
|
|
7578
|
+
case "median":
|
|
7579
|
+
return MetricAggregator3.P50;
|
|
7580
|
+
case "p95":
|
|
7581
|
+
return MetricAggregator3.P95;
|
|
7582
|
+
}
|
|
7583
|
+
throw new Error(
|
|
7584
|
+
`unknown aggregator ${JSON.stringify(s)}: expected one of sum, avg, count, min, max, p50, p95`
|
|
7585
|
+
);
|
|
7586
|
+
}
|
|
7587
|
+
function renderStatsTable(s) {
|
|
7588
|
+
const winFrom = s.windowFrom?.seconds ? new Date(Number(s.windowFrom.seconds) * 1e3).toISOString().slice(0, 19) : "?";
|
|
7589
|
+
const winTo = s.windowTo?.seconds ? new Date(Number(s.windowTo.seconds) * 1e3).toISOString().slice(0, 19) : "?";
|
|
7590
|
+
console.log("");
|
|
7591
|
+
console.log(`Experiment ${s.experimentKey} (id ${s.experimentId})`);
|
|
7592
|
+
console.log(
|
|
7593
|
+
`Window ${winFrom} \u2192 ${winTo} grain: ${s.dailyGrain || "day"} total assignments: ${s.totalAssignments}`
|
|
7594
|
+
);
|
|
7595
|
+
console.log("");
|
|
7596
|
+
console.log("Variants");
|
|
7597
|
+
console.log(
|
|
7598
|
+
" " + padCols(["variant", "control", "assignments", "share"], [16, 8, 12, 8])
|
|
7599
|
+
);
|
|
7600
|
+
console.log(" " + "-".repeat(48));
|
|
7601
|
+
for (const v of s.variants) {
|
|
7602
|
+
const share = s.totalAssignments > 0 ? (v.assignmentCount / s.totalAssignments * 100).toFixed(1) + "%" : "-";
|
|
7603
|
+
console.log(
|
|
7604
|
+
" " + padCols(
|
|
7605
|
+
[v.variantKey, v.isControl ? "\u2713" : "", String(v.assignmentCount), share],
|
|
7606
|
+
[16, 8, 12, 8]
|
|
7607
|
+
)
|
|
7608
|
+
);
|
|
7609
|
+
}
|
|
7610
|
+
console.log("");
|
|
7611
|
+
const familySize = familySizeOf(s);
|
|
7612
|
+
for (const goal of s.goals) {
|
|
7613
|
+
console.log(`Goal: ${goal.goalKey}${goal.isPrimary ? " (primary)" : ""}`);
|
|
7614
|
+
console.log(
|
|
7615
|
+
" " + padCols(
|
|
7616
|
+
["variant", "count", "unique", "rate", "lift", "p (raw)", "p (corr)", "sig"],
|
|
7617
|
+
[16, 8, 8, 9, 9, 10, 10, 5]
|
|
7618
|
+
)
|
|
7619
|
+
);
|
|
7620
|
+
console.log(" " + "-".repeat(75));
|
|
7621
|
+
for (const v of s.variants) {
|
|
7622
|
+
const cell = v.goalStats.find((g) => g.goalKey === goal.goalKey);
|
|
7623
|
+
if (!cell) continue;
|
|
7624
|
+
const rate = (cell.conversionRate * 100).toFixed(2) + "%";
|
|
7625
|
+
if (v.isControl) {
|
|
7626
|
+
console.log(
|
|
7627
|
+
" " + padCols(
|
|
7628
|
+
[v.variantKey, String(cell.conversionEventCount), String(cell.uniqueConverters), rate, "-", "-", "-", "-"],
|
|
7629
|
+
[16, 8, 8, 9, 9, 10, 10, 5]
|
|
7630
|
+
)
|
|
7631
|
+
);
|
|
7632
|
+
continue;
|
|
7633
|
+
}
|
|
7634
|
+
const sig = cell.significance;
|
|
7635
|
+
const rawP = sig?.pValue ?? 1;
|
|
7636
|
+
const corrP = Math.min(1, rawP * familySize);
|
|
7637
|
+
const lift = sig ? (sig.relativeLift >= 0 ? "+" : "") + (sig.relativeLift * 100).toFixed(1) + "%" : "-";
|
|
7638
|
+
const sigFlag = corrP < 0.05 && sig?.isSignificant ? "\u2713" : "";
|
|
7639
|
+
console.log(
|
|
7640
|
+
" " + padCols(
|
|
7641
|
+
[
|
|
7642
|
+
v.variantKey,
|
|
7643
|
+
String(cell.conversionEventCount),
|
|
7644
|
+
String(cell.uniqueConverters),
|
|
7645
|
+
rate,
|
|
7646
|
+
lift,
|
|
7647
|
+
rawP.toFixed(4),
|
|
7648
|
+
corrP.toFixed(4),
|
|
7649
|
+
sigFlag
|
|
7650
|
+
],
|
|
7651
|
+
[16, 8, 8, 9, 9, 10, 10, 5]
|
|
7652
|
+
)
|
|
7653
|
+
);
|
|
7654
|
+
}
|
|
7655
|
+
console.log("");
|
|
7656
|
+
}
|
|
7657
|
+
if (s.metricStats.length > 0) {
|
|
7658
|
+
const byMetric = /* @__PURE__ */ new Map();
|
|
7659
|
+
for (const m of s.metricStats) {
|
|
7660
|
+
const list = byMetric.get(m.metricName) ?? [];
|
|
7661
|
+
list.push(m);
|
|
7662
|
+
byMetric.set(m.metricName, list);
|
|
7663
|
+
}
|
|
7664
|
+
for (const [name, rows] of byMetric) {
|
|
7665
|
+
console.log(`Metric: ${name}`);
|
|
7666
|
+
console.log(
|
|
7667
|
+
" " + padCols(
|
|
7668
|
+
["variant", "value", "n", "skipped", "lift", "p", "sig"],
|
|
7669
|
+
[16, 12, 8, 8, 9, 8, 5]
|
|
7670
|
+
)
|
|
7671
|
+
);
|
|
7672
|
+
console.log(" " + "-".repeat(70));
|
|
7673
|
+
for (const m of rows) {
|
|
7674
|
+
const sig = m.significance;
|
|
7675
|
+
const lift = sig ? (sig.relativeLift >= 0 ? "+" : "") + (sig.relativeLift * 100).toFixed(1) + "%" : "-";
|
|
7676
|
+
const p = sig ? sig.pValue.toFixed(4) : "-";
|
|
7677
|
+
const sigFlag = sig?.isSignificant ? "\u2713" : "";
|
|
7678
|
+
console.log(
|
|
7679
|
+
" " + padCols(
|
|
7680
|
+
[
|
|
7681
|
+
m.variantKey,
|
|
7682
|
+
m.value.toFixed(2),
|
|
7683
|
+
String(m.sampleSize),
|
|
7684
|
+
String(m.skippedRowCount),
|
|
7685
|
+
lift,
|
|
7686
|
+
p,
|
|
7687
|
+
sigFlag
|
|
7688
|
+
],
|
|
7689
|
+
[16, 12, 8, 8, 9, 8, 5]
|
|
7690
|
+
)
|
|
7691
|
+
);
|
|
7692
|
+
}
|
|
7693
|
+
console.log("");
|
|
7694
|
+
}
|
|
7695
|
+
}
|
|
7696
|
+
const winners = winnersOnPrimary(s, familySize);
|
|
7697
|
+
if (winners.length > 0) {
|
|
7698
|
+
console.log(`Significant on primary goal (Bonferroni-corrected): ${winners.join(", ")}`);
|
|
7699
|
+
} else {
|
|
7700
|
+
console.log("No variant has reached significance on the primary goal.");
|
|
7701
|
+
}
|
|
7702
|
+
}
|
|
7703
|
+
function familySizeOf(s) {
|
|
7704
|
+
const variantCount = s.variants.filter((v) => !v.isControl).length;
|
|
7705
|
+
const goalCount = s.goals.length;
|
|
7706
|
+
return Math.max(1, variantCount * goalCount);
|
|
7707
|
+
}
|
|
7708
|
+
function winnersOnPrimary(s, familySize) {
|
|
7709
|
+
const primary = s.goals.find((g) => g.isPrimary);
|
|
7710
|
+
if (!primary) return [];
|
|
7711
|
+
const out = [];
|
|
7712
|
+
for (const v of s.variants) {
|
|
7713
|
+
if (v.isControl) continue;
|
|
7714
|
+
const cell = v.goalStats.find((g) => g.goalKey === primary.goalKey);
|
|
7715
|
+
if (!cell?.significance) continue;
|
|
7716
|
+
const corrP = Math.min(1, cell.significance.pValue * familySize);
|
|
7717
|
+
if (corrP < 0.05 && cell.significance.isSignificant) {
|
|
7718
|
+
out.push(v.variantKey);
|
|
7719
|
+
}
|
|
7720
|
+
}
|
|
7721
|
+
return out;
|
|
7722
|
+
}
|
|
7723
|
+
function padCols(values, widths) {
|
|
7724
|
+
return values.map((v, i) => {
|
|
7725
|
+
const w = widths[i] ?? 12;
|
|
7726
|
+
const s = v ?? "";
|
|
7727
|
+
if (s.length >= w) return s.slice(0, w);
|
|
7728
|
+
return s + " ".repeat(w - s.length);
|
|
7729
|
+
}).join(" ");
|
|
7730
|
+
}
|
|
7490
7731
|
|
|
7491
7732
|
// src/commands/schedules.ts
|
|
7492
7733
|
import { CronScheduleSchema } from "@eide/foir-proto-ts/schedules/v1/schedules_pb";
|
|
@@ -8874,7 +9115,9 @@ function classToLabel(n) {
|
|
|
8874
9115
|
}
|
|
8875
9116
|
|
|
8876
9117
|
// src/commands/secrets.ts
|
|
9118
|
+
import { existsSync as existsSync6 } from "fs";
|
|
8877
9119
|
import { promises as fs5 } from "fs";
|
|
9120
|
+
import { resolve as resolvePath } from "path";
|
|
8878
9121
|
function registerSecretsCommands(program2, globalOpts) {
|
|
8879
9122
|
const secrets = program2.command("secrets").description("Manage vault secrets");
|
|
8880
9123
|
secrets.command("put").description("Store a new secret and print its ref").option("--label <label>", "Optional human-readable label").option("--app <name>", "Owner: app name (defaults to project-owned)").option("--file <path>", "Read plaintext from file (binary-safe)").option("--value <plaintext>", "Plaintext value (string only; prefer --file for binary)").action(
|
|
@@ -8992,6 +9235,110 @@ function registerSecretsCommands(program2, globalOpts) {
|
|
|
8992
9235
|
success(`Restored ${ref}`);
|
|
8993
9236
|
})
|
|
8994
9237
|
);
|
|
9238
|
+
secrets.command("push").description(
|
|
9239
|
+
"Reconcile foir.secrets.ts against the project vault: create missing secrets, optionally rotate existing ones"
|
|
9240
|
+
).option("--config <path>", "Path to foir.secrets.ts (default: auto-discover)").option("--plaintext <path>", "Path to local.foir.secrets.ts (default: auto-discover)").option("--rotate", "Rotate plaintext for secrets that already exist").option("--dry-run", "Show what would change without calling PutSecret/RotateSecret").action(
|
|
9241
|
+
withErrorHandler(globalOpts, async (cmdOpts) => {
|
|
9242
|
+
const opts = globalOpts();
|
|
9243
|
+
const resolved = await requireProject2(opts);
|
|
9244
|
+
const configPath = await resolveSecretsConfigPath(
|
|
9245
|
+
typeof cmdOpts.config === "string" ? cmdOpts.config : void 0
|
|
9246
|
+
);
|
|
9247
|
+
const plaintextPath = await resolvePlaintextPath(
|
|
9248
|
+
typeof cmdOpts.plaintext === "string" ? cmdOpts.plaintext : void 0
|
|
9249
|
+
);
|
|
9250
|
+
const declared = await loadConfig(configPath);
|
|
9251
|
+
validateSecretsConfig(declared, configPath);
|
|
9252
|
+
const plaintextSource = plaintextPath ? await loadConfig(plaintextPath) : {};
|
|
9253
|
+
const dryRun = !!cmdOpts["dry-run"];
|
|
9254
|
+
const rotate = !!cmdOpts.rotate;
|
|
9255
|
+
const client = await createPlatformClient(opts);
|
|
9256
|
+
const groups = groupDeclarations(declared.secrets);
|
|
9257
|
+
const planEntries = [];
|
|
9258
|
+
for (const group of groups) {
|
|
9259
|
+
const existing = await client.secrets.list({
|
|
9260
|
+
tenantId: resolved.project.tenantId,
|
|
9261
|
+
projectId: resolved.project.id,
|
|
9262
|
+
ownerKind: group.ownerKind,
|
|
9263
|
+
ownerId: group.ownerId ?? "",
|
|
9264
|
+
includeSoftDeleted: false
|
|
9265
|
+
});
|
|
9266
|
+
const existingByLabel = new Map(
|
|
9267
|
+
existing.filter((s) => s.label).map((s) => [s.label, s])
|
|
9268
|
+
);
|
|
9269
|
+
for (const decl of group.decls) {
|
|
9270
|
+
const remote = existingByLabel.get(decl.label);
|
|
9271
|
+
if (remote && !rotate) {
|
|
9272
|
+
planEntries.push({ decl, action: "skip", existingRef: remote.ref });
|
|
9273
|
+
continue;
|
|
9274
|
+
}
|
|
9275
|
+
if (remote && rotate) {
|
|
9276
|
+
planEntries.push({ decl, action: "rotate", existingRef: remote.ref });
|
|
9277
|
+
continue;
|
|
9278
|
+
}
|
|
9279
|
+
planEntries.push({ decl, action: "create" });
|
|
9280
|
+
}
|
|
9281
|
+
}
|
|
9282
|
+
const needsPlaintext = planEntries.filter(
|
|
9283
|
+
(e) => e.action === "create" || e.action === "rotate"
|
|
9284
|
+
);
|
|
9285
|
+
const missingPlaintext = needsPlaintext.map((e) => e.decl.label).filter((label) => !(label in plaintextSource));
|
|
9286
|
+
if (missingPlaintext.length > 0 && !dryRun) {
|
|
9287
|
+
throw new Error(
|
|
9288
|
+
`Missing plaintext for ${missingPlaintext.length} secret(s) in ${plaintextPath ?? "local.foir.secrets.ts"}: ${missingPlaintext.join(", ")}. Add an entry for each label, or pass --dry-run to preview without resolving plaintext.`
|
|
9289
|
+
);
|
|
9290
|
+
}
|
|
9291
|
+
if (dryRun) {
|
|
9292
|
+
formatPushPlan(planEntries, opts);
|
|
9293
|
+
return;
|
|
9294
|
+
}
|
|
9295
|
+
const results = [];
|
|
9296
|
+
for (const entry of planEntries) {
|
|
9297
|
+
if (entry.action === "skip") {
|
|
9298
|
+
results.push({ ...entry, ref: entry.existingRef });
|
|
9299
|
+
continue;
|
|
9300
|
+
}
|
|
9301
|
+
const raw = plaintextSource[entry.decl.label];
|
|
9302
|
+
if (raw === void 0) {
|
|
9303
|
+
throw new Error(`unreachable: missing plaintext for ${entry.decl.label}`);
|
|
9304
|
+
}
|
|
9305
|
+
const plaintext = toUint8Array(raw);
|
|
9306
|
+
if (entry.action === "create") {
|
|
9307
|
+
const ref = await client.secrets.put({
|
|
9308
|
+
tenantId: resolved.project.tenantId,
|
|
9309
|
+
projectId: resolved.project.id,
|
|
9310
|
+
ownerKind: ownerKindFromString(entry.decl.ownerKind),
|
|
9311
|
+
ownerId: entry.decl.ownerId,
|
|
9312
|
+
label: entry.decl.label,
|
|
9313
|
+
plaintext
|
|
9314
|
+
});
|
|
9315
|
+
results.push({ ...entry, ref });
|
|
9316
|
+
} else {
|
|
9317
|
+
const newRef = await client.secrets.rotate(entry.existingRef, plaintext);
|
|
9318
|
+
results.push({ ...entry, ref: newRef });
|
|
9319
|
+
}
|
|
9320
|
+
}
|
|
9321
|
+
if (opts.json || opts.jsonl) {
|
|
9322
|
+
formatOutput(
|
|
9323
|
+
results.map((r) => ({
|
|
9324
|
+
label: r.decl.label,
|
|
9325
|
+
ownerKind: r.decl.ownerKind,
|
|
9326
|
+
ownerId: r.decl.ownerId,
|
|
9327
|
+
action: r.action,
|
|
9328
|
+
ref: r.ref
|
|
9329
|
+
})),
|
|
9330
|
+
opts
|
|
9331
|
+
);
|
|
9332
|
+
return;
|
|
9333
|
+
}
|
|
9334
|
+
const created = results.filter((r) => r.action === "create").length;
|
|
9335
|
+
const rotated = results.filter((r) => r.action === "rotate").length;
|
|
9336
|
+
const skipped = results.filter((r) => r.action === "skip").length;
|
|
9337
|
+
success(
|
|
9338
|
+
`secrets push: ${created} created, ${rotated} rotated, ${skipped} unchanged`
|
|
9339
|
+
);
|
|
9340
|
+
})
|
|
9341
|
+
);
|
|
8995
9342
|
secrets.command("purge").description("Drop every soft-deleted secret past its TTL (admin-only)").option("--confirm", "Skip confirmation prompt").action(
|
|
8996
9343
|
withErrorHandler(globalOpts, async (cmdOpts) => {
|
|
8997
9344
|
const opts = globalOpts();
|
|
@@ -9054,6 +9401,121 @@ function tsToString(t) {
|
|
|
9054
9401
|
const nanos = t.nanos ?? 0;
|
|
9055
9402
|
return new Date(seconds * 1e3 + Math.floor(nanos / 1e6)).toISOString();
|
|
9056
9403
|
}
|
|
9404
|
+
var SECRETS_CONFIG_NAMES = [
|
|
9405
|
+
"foir.secrets.ts",
|
|
9406
|
+
"foir.secrets.js",
|
|
9407
|
+
"foir.secrets.mjs",
|
|
9408
|
+
"foir.secrets.json"
|
|
9409
|
+
];
|
|
9410
|
+
var PLAINTEXT_CONFIG_NAMES = [
|
|
9411
|
+
"local.foir.secrets.ts",
|
|
9412
|
+
"local.foir.secrets.js",
|
|
9413
|
+
"local.foir.secrets.mjs",
|
|
9414
|
+
"local.foir.secrets.json"
|
|
9415
|
+
];
|
|
9416
|
+
async function resolveSecretsConfigPath(explicit) {
|
|
9417
|
+
if (explicit) {
|
|
9418
|
+
if (!existsSync6(explicit)) {
|
|
9419
|
+
throw new Error(`Secrets config not found: ${explicit}`);
|
|
9420
|
+
}
|
|
9421
|
+
return resolvePath(explicit);
|
|
9422
|
+
}
|
|
9423
|
+
for (const name of SECRETS_CONFIG_NAMES) {
|
|
9424
|
+
const path3 = resolvePath(process.cwd(), name);
|
|
9425
|
+
if (existsSync6(path3)) return path3;
|
|
9426
|
+
}
|
|
9427
|
+
throw new Error(
|
|
9428
|
+
`No secrets config found. Looked for: ${SECRETS_CONFIG_NAMES.join(", ")}.`
|
|
9429
|
+
);
|
|
9430
|
+
}
|
|
9431
|
+
async function resolvePlaintextPath(explicit) {
|
|
9432
|
+
if (explicit) {
|
|
9433
|
+
if (!existsSync6(explicit)) {
|
|
9434
|
+
throw new Error(`Plaintext file not found: ${explicit}`);
|
|
9435
|
+
}
|
|
9436
|
+
return resolvePath(explicit);
|
|
9437
|
+
}
|
|
9438
|
+
for (const name of PLAINTEXT_CONFIG_NAMES) {
|
|
9439
|
+
const path3 = resolvePath(process.cwd(), name);
|
|
9440
|
+
if (existsSync6(path3)) return path3;
|
|
9441
|
+
}
|
|
9442
|
+
return null;
|
|
9443
|
+
}
|
|
9444
|
+
function validateSecretsConfig(cfg, path3) {
|
|
9445
|
+
if (!cfg || !Array.isArray(cfg.secrets)) {
|
|
9446
|
+
throw new Error(`${path3}: default export must be { secrets: [...] }`);
|
|
9447
|
+
}
|
|
9448
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9449
|
+
for (const decl of cfg.secrets) {
|
|
9450
|
+
if (!decl.label) {
|
|
9451
|
+
throw new Error(`${path3}: every secret declaration needs a label`);
|
|
9452
|
+
}
|
|
9453
|
+
if (decl.ownerKind !== "project" && decl.ownerKind !== "app") {
|
|
9454
|
+
throw new Error(
|
|
9455
|
+
`${path3}: ownerKind must be "project" or "app" for label ${decl.label}, got ${decl.ownerKind}`
|
|
9456
|
+
);
|
|
9457
|
+
}
|
|
9458
|
+
if (decl.ownerKind === "app" && !decl.ownerId) {
|
|
9459
|
+
throw new Error(
|
|
9460
|
+
`${path3}: app-owned secret ${decl.label} requires ownerId`
|
|
9461
|
+
);
|
|
9462
|
+
}
|
|
9463
|
+
const dedupeKey = `${decl.ownerKind}|${decl.ownerId ?? ""}|${decl.label}`;
|
|
9464
|
+
if (seen.has(dedupeKey)) {
|
|
9465
|
+
throw new Error(`${path3}: duplicate secret declaration: ${dedupeKey}`);
|
|
9466
|
+
}
|
|
9467
|
+
seen.add(dedupeKey);
|
|
9468
|
+
}
|
|
9469
|
+
}
|
|
9470
|
+
function groupDeclarations(decls) {
|
|
9471
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
9472
|
+
for (const decl of decls) {
|
|
9473
|
+
const key = `${decl.ownerKind}|${decl.ownerId ?? ""}`;
|
|
9474
|
+
const existing = byKey.get(key);
|
|
9475
|
+
if (existing) {
|
|
9476
|
+
existing.decls.push(decl);
|
|
9477
|
+
} else {
|
|
9478
|
+
byKey.set(key, {
|
|
9479
|
+
ownerKind: ownerKindFromString(decl.ownerKind),
|
|
9480
|
+
ownerId: decl.ownerId,
|
|
9481
|
+
decls: [decl]
|
|
9482
|
+
});
|
|
9483
|
+
}
|
|
9484
|
+
}
|
|
9485
|
+
return Array.from(byKey.values());
|
|
9486
|
+
}
|
|
9487
|
+
function ownerKindFromString(s) {
|
|
9488
|
+
return s === "app" ? OwnerKind.APP : OwnerKind.PROJECT;
|
|
9489
|
+
}
|
|
9490
|
+
function toUint8Array(v) {
|
|
9491
|
+
if (v instanceof Uint8Array) return v;
|
|
9492
|
+
return new TextEncoder().encode(v);
|
|
9493
|
+
}
|
|
9494
|
+
function formatPushPlan(plan, opts) {
|
|
9495
|
+
if (opts.json || opts.jsonl) {
|
|
9496
|
+
formatOutput(
|
|
9497
|
+
plan.map((e) => ({
|
|
9498
|
+
label: e.decl.label,
|
|
9499
|
+
ownerKind: e.decl.ownerKind,
|
|
9500
|
+
ownerId: e.decl.ownerId,
|
|
9501
|
+
action: e.action,
|
|
9502
|
+
existingRef: e.action === "create" ? void 0 : e.existingRef
|
|
9503
|
+
})),
|
|
9504
|
+
opts
|
|
9505
|
+
);
|
|
9506
|
+
return;
|
|
9507
|
+
}
|
|
9508
|
+
if (plan.length === 0) {
|
|
9509
|
+
warn("No secrets declared.");
|
|
9510
|
+
return;
|
|
9511
|
+
}
|
|
9512
|
+
for (const entry of plan) {
|
|
9513
|
+
const owner = entry.decl.ownerKind === "app" ? `app:${entry.decl.ownerId}` : "project";
|
|
9514
|
+
const action = entry.action.padEnd(7);
|
|
9515
|
+
const ref = entry.action === "create" ? "" : entry.existingRef;
|
|
9516
|
+
console.log(`${action} ${owner.padEnd(24)} ${entry.decl.label.padEnd(32)} ${ref}`);
|
|
9517
|
+
}
|
|
9518
|
+
}
|
|
9057
9519
|
|
|
9058
9520
|
// src/cli.ts
|
|
9059
9521
|
var __filename = fileURLToPath(import.meta.url);
|
|
@@ -354,5 +354,38 @@ declare function defineAuthProvider(provider: ApplyConfigAuthProviderInput): App
|
|
|
354
354
|
declare function defineHook(hook: ApplyConfigHookInput): ApplyConfigHookInput;
|
|
355
355
|
/** Define an editor placement (sidebar or main-editor tab). */
|
|
356
356
|
declare function definePlacement(placement: ApplyConfigPlacementInput): ApplyConfigPlacementInput;
|
|
357
|
+
type SecretOwnerKind = 'project' | 'app';
|
|
358
|
+
/**
|
|
359
|
+
* One declared secret. `label` is the human-readable handle the
|
|
360
|
+
* reconciler uses to look the secret up in the vault — no opaque ref
|
|
361
|
+
* is committed. `ownerId` is required for app-owned secrets and ignored
|
|
362
|
+
* for project-owned ones.
|
|
363
|
+
*/
|
|
364
|
+
interface SecretDeclaration {
|
|
365
|
+
ownerKind: SecretOwnerKind;
|
|
366
|
+
ownerId?: string;
|
|
367
|
+
label: string;
|
|
368
|
+
}
|
|
369
|
+
/** Shape of a `foir.secrets.ts` default export. */
|
|
370
|
+
interface FoirSecretsConfig {
|
|
371
|
+
secrets: SecretDeclaration[];
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Type-safe identity helper for `foir.secrets.ts`:
|
|
375
|
+
*
|
|
376
|
+
* ```ts
|
|
377
|
+
* import { defineSecrets } from '@eide/foir-cli/configs';
|
|
378
|
+
* export default defineSecrets({
|
|
379
|
+
* secrets: [
|
|
380
|
+
* { ownerKind: 'project', label: 'deepl_api_key' },
|
|
381
|
+
* ],
|
|
382
|
+
* });
|
|
383
|
+
* ```
|
|
384
|
+
*
|
|
385
|
+
* Plaintext lives in a sibling `local.foir.secrets.ts` (gitignored)
|
|
386
|
+
* keyed by label, or in env vars. Production never runs the
|
|
387
|
+
* reconciler — operators set production secrets through the admin UI.
|
|
388
|
+
*/
|
|
389
|
+
declare function defineSecrets(config: FoirSecretsConfig): FoirSecretsConfig;
|
|
357
390
|
|
|
358
|
-
export { type AppInput, type AppPlacementFieldChoiceInput, type AppSinkMappingInput, type AppSourceMappingInput, type ApplyConfigApiKeyInput, type ApplyConfigAuthProviderInput, type ApplyConfigHookInput, type ApplyConfigInput, type ApplyConfigModelInput, type ApplyConfigOperationInput, type ApplyConfigPlacementInput, type ApplyConfigProjectInput, type ApplyConfigProjectSettingsInput, type ApplyConfigScheduleInput, type ApplyConfigSegmentInput, type ExpressionPrecondition, type FieldDefinitionInput, type Precondition, type QuotaRule, type SegmentPrecondition, type SelectFieldConfig, type SelectFieldDefinitionInput, defineAuthProvider, defineConfig, defineField, defineHook, defineModel, defineOperation, definePlacement, defineSchedule, defineSegment, defineSelectField };
|
|
391
|
+
export { type AppInput, type AppPlacementFieldChoiceInput, type AppSinkMappingInput, type AppSourceMappingInput, type ApplyConfigApiKeyInput, type ApplyConfigAuthProviderInput, type ApplyConfigHookInput, type ApplyConfigInput, type ApplyConfigModelInput, type ApplyConfigOperationInput, type ApplyConfigPlacementInput, type ApplyConfigProjectInput, type ApplyConfigProjectSettingsInput, type ApplyConfigScheduleInput, type ApplyConfigSegmentInput, type ExpressionPrecondition, type FieldDefinitionInput, type FoirSecretsConfig, type Precondition, type QuotaRule, type SecretDeclaration, type SecretOwnerKind, type SegmentPrecondition, type SelectFieldConfig, type SelectFieldDefinitionInput, defineAuthProvider, defineConfig, defineField, defineHook, defineModel, defineOperation, definePlacement, defineSchedule, defineSecrets, defineSegment, defineSelectField };
|
|
@@ -29,6 +29,9 @@ function defineHook(hook) {
|
|
|
29
29
|
function definePlacement(placement) {
|
|
30
30
|
return placement;
|
|
31
31
|
}
|
|
32
|
+
function defineSecrets(config) {
|
|
33
|
+
return config;
|
|
34
|
+
}
|
|
32
35
|
export {
|
|
33
36
|
defineAuthProvider,
|
|
34
37
|
defineConfig,
|
|
@@ -38,6 +41,7 @@ export {
|
|
|
38
41
|
defineOperation,
|
|
39
42
|
definePlacement,
|
|
40
43
|
defineSchedule,
|
|
44
|
+
defineSecrets,
|
|
41
45
|
defineSegment,
|
|
42
46
|
defineSelectField
|
|
43
47
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eide/foir-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Universal platform CLI for Foir platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@bufbuild/protovalidate": "^1.1.1",
|
|
51
51
|
"@connectrpc/connect": "^2.0.0",
|
|
52
52
|
"@connectrpc/connect-node": "^2.0.0",
|
|
53
|
-
"@eide/foir-proto-ts": "^0.
|
|
53
|
+
"@eide/foir-proto-ts": "^0.45.0",
|
|
54
54
|
"chalk": "^5.3.0",
|
|
55
55
|
"commander": "^12.1.0",
|
|
56
56
|
"dotenv": "^16.4.5",
|