@dragonmastery/tamer 0.29.0 → 0.31.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/README.md +2 -1
- package/dist/{apply-BOABC3UB.mjs → apply-BjrYbyHn.mjs} +14 -16
- package/dist/{apply-BOABC3UB.mjs.map → apply-BjrYbyHn.mjs.map} +1 -1
- package/dist/{applyTarget-GWDEOXeY.mjs → applyTarget-Ce_mtRQX.mjs} +3 -3
- package/dist/{applyTarget-GWDEOXeY.mjs.map → applyTarget-Ce_mtRQX.mjs.map} +1 -1
- package/dist/{bootstrap-BxwxC_2Z.mjs → bootstrap-D__dHw1w.mjs} +6 -6
- package/dist/bootstrap-D__dHw1w.mjs.map +1 -0
- package/dist/{buildDispatchUploadForm-BoUB93b3.mjs → buildDispatchUploadForm-CVnPmHg4.mjs} +1 -1
- package/dist/{buildDispatchUploadForm-BoUB93b3.mjs.map → buildDispatchUploadForm-CVnPmHg4.mjs.map} +1 -1
- package/dist/{cloudflareSnapshot-DzPuCRTh.mjs → cloudflareSnapshot-C6cF8GG8.mjs} +5 -7
- package/dist/{cloudflareSnapshot-DzPuCRTh.mjs.map → cloudflareSnapshot-C6cF8GG8.mjs.map} +1 -1
- package/dist/{deploy-C0edCpn9.mjs → deploy-C6fX9td0.mjs} +23 -11
- package/dist/deploy-C6fX9td0.mjs.map +1 -0
- package/dist/{destroy-tenant-U0t7BeJ0.mjs → destroy-tenant-T_94ed9x.mjs} +2 -4
- package/dist/{destroy-tenant-U0t7BeJ0.mjs.map → destroy-tenant-T_94ed9x.mjs.map} +1 -1
- package/dist/{destroy-DzgA4lCA.mjs → destroy-vfk2Zbfj.mjs} +11 -13
- package/dist/{destroy-DzgA4lCA.mjs.map → destroy-vfk2Zbfj.mjs.map} +1 -1
- package/dist/{dev-CZbKfdFw.mjs → dev-BLthyLml.mjs} +8 -10
- package/dist/{dev-CZbKfdFw.mjs.map → dev-BLthyLml.mjs.map} +1 -1
- package/dist/{dns-records.resolve-C2T0m4NG.mjs → dns-records.resolve-8a_eHfVI.mjs} +1 -1
- package/dist/{dns-records.resolve-DwBR_1WI.mjs → dns-records.resolve-BB2agPAb.mjs} +1 -1
- package/dist/{dns-records.resolve-DwBR_1WI.mjs.map → dns-records.resolve-BB2agPAb.mjs.map} +1 -1
- package/dist/{dns-records.sync-Bpzz9H0s.mjs → dns-records.sync-DqYROe07.mjs} +3 -3
- package/dist/{dns-records.sync-Bpzz9H0s.mjs.map → dns-records.sync-DqYROe07.mjs.map} +1 -1
- package/dist/{doctor-C_hs7k2D.mjs → doctor-32YLAXXl.mjs} +2 -2
- package/dist/{doctor-C_hs7k2D.mjs.map → doctor-32YLAXXl.mjs.map} +1 -1
- package/dist/drift-BCxWdYHG.mjs +8 -0
- package/dist/{drift-B5bpkI0i.mjs → drift-CeemyFqL.mjs} +37 -9
- package/dist/drift-CeemyFqL.mjs.map +1 -0
- package/dist/{events-BIznt8Sj.mjs → events-otk0l3aJ.mjs} +2 -4
- package/dist/{events-BIznt8Sj.mjs.map → events-otk0l3aJ.mjs.map} +1 -1
- package/dist/{generator-Ba-vqyBG.mjs → generator-gvCy7ouY.mjs} +6 -5
- package/dist/generator-gvCy7ouY.mjs.map +1 -0
- package/dist/{import-B0dlwKoQ.mjs → import-OvohE-H2.mjs} +8 -8
- package/dist/import-OvohE-H2.mjs.map +1 -0
- package/dist/index.d.mts +314 -38
- package/dist/index.d.mts.map +1 -1
- package/dist/{logpush-job-DsRkOORJ.mjs → logpush-job-DJPlpnRu.mjs} +2 -2
- package/dist/{logpush-job-DsRkOORJ.mjs.map → logpush-job-DJPlpnRu.mjs.map} +1 -1
- package/dist/{migrate-BpW6JkIg.mjs → migrate-CroDjbJz.mjs} +6 -8
- package/dist/{migrate-BpW6JkIg.mjs.map → migrate-CroDjbJz.mjs.map} +1 -1
- package/dist/normalize-DVSTRZhO.mjs.map +1 -1
- package/dist/{plan-Do5rE-c5.mjs → plan-C2urqJOz.mjs} +39 -14
- package/dist/plan-C2urqJOz.mjs.map +1 -0
- package/dist/{planFormat-CJw8Kq2s.mjs → planFormat-5XMJK879.mjs} +1 -1
- package/dist/{planFormat-CJw8Kq2s.mjs.map → planFormat-5XMJK879.mjs.map} +1 -1
- package/dist/{provision-tenant-Wfck-2Oa.mjs → provision-tenant-BJ1KugON.mjs} +6 -8
- package/dist/{provision-tenant-Wfck-2Oa.mjs.map → provision-tenant-BJ1KugON.mjs.map} +1 -1
- package/dist/{r2S3EmptyBucket-DD81ZWQ7.mjs → r2S3EmptyBucket-B9_pHfvB.mjs} +1 -1
- package/dist/{r2S3EmptyBucket-DD81ZWQ7.mjs.map → r2S3EmptyBucket-B9_pHfvB.mjs.map} +1 -1
- package/dist/{fetchStackImports-C-1THPYL.mjs → registry-EWWdkLf7.mjs} +272 -1065
- package/dist/registry-EWWdkLf7.mjs.map +1 -0
- package/dist/secrets-CnzjvndT.mjs +3 -0
- package/dist/{stackOutputs-CQQHtdPA.mjs → stackOutputs-Cltzl2g0.mjs} +2 -2
- package/dist/{stackOutputs-CQQHtdPA.mjs.map → stackOutputs-Cltzl2g0.mjs.map} +1 -1
- package/dist/{status-D5GLpWyn.mjs → status-DkkS5lc9.mjs} +7 -9
- package/dist/{status-D5GLpWyn.mjs.map → status-DkkS5lc9.mjs.map} +1 -1
- package/dist/{sync-B_pyPi7Z.mjs → sync-CpfxqlOx.mjs} +7 -9
- package/dist/{sync-B_pyPi7Z.mjs.map → sync-CpfxqlOx.mjs.map} +1 -1
- package/dist/tamer.mjs +4428 -213
- package/dist/tamer.mjs.map +1 -1
- package/dist/{tamerArtifactsR2-Ccgplu2Q.mjs → tamerArtifactsR2-DnUJmxnO.mjs} +2 -2
- package/dist/{tamerArtifactsR2-Ccgplu2Q.mjs.map → tamerArtifactsR2-DnUJmxnO.mjs.map} +1 -1
- package/dist/{types-JrdlG7Dy.mjs → types-BzzHwIdw.mjs} +6 -8
- package/dist/{types-JrdlG7Dy.mjs.map → types-BzzHwIdw.mjs.map} +1 -1
- package/dist/{verifyPlanFile-ah_4tvTu.mjs → verifyPlanFile-BmEadIqm.mjs} +2 -2
- package/dist/{verifyPlanFile-ah_4tvTu.mjs.map → verifyPlanFile-BmEadIqm.mjs.map} +1 -1
- package/dist/{wfp-delete-BhuUrBUA.mjs → wfp-delete-CDBFqmrM.mjs} +2 -3
- package/dist/{wfp-delete-BhuUrBUA.mjs.map → wfp-delete-CDBFqmrM.mjs.map} +1 -1
- package/dist/{wfp-put-DL0mJNNz.mjs → wfp-put-BrwICc9i.mjs} +3 -4
- package/dist/{wfp-put-DL0mJNNz.mjs.map → wfp-put-BrwICc9i.mjs.map} +1 -1
- package/dist/{worker-route-CMbtozNa.mjs → worker-route-x8q3K4-z.mjs} +3 -4
- package/dist/{worker-route-CMbtozNa.mjs.map → worker-route-x8q3K4-z.mjs.map} +1 -1
- package/dist/{workers-C-oeZhdD.mjs → workers-D3Ekf3mF.mjs} +3 -4
- package/dist/{workers-C-oeZhdD.mjs.map → workers-D3Ekf3mF.mjs.map} +1 -1
- package/dist/{wranglerSpawn-DmEz0ldT.mjs → wranglerSpawn-CUlo2qOJ.mjs} +1 -1
- package/dist/{wranglerSpawn-DmEz0ldT.mjs.map → wranglerSpawn-CUlo2qOJ.mjs.map} +1 -1
- package/dist/{zoneResolver-VoxLHM4N.mjs → zoneResolver-DNNNmO_w.mjs} +1 -1
- package/dist/{zoneResolver-VoxLHM4N.mjs.map → zoneResolver-DNNNmO_w.mjs.map} +1 -1
- package/package.json +1 -1
- package/dist/CFApiClient-DhbyyV71.mjs +0 -868
- package/dist/CFApiClient-DhbyyV71.mjs.map +0 -1
- package/dist/StateManager-DTqtLLVX.mjs +0 -760
- package/dist/StateManager-DTqtLLVX.mjs.map +0 -1
- package/dist/bootstrap-BxwxC_2Z.mjs.map +0 -1
- package/dist/deploy-C0edCpn9.mjs.map +0 -1
- package/dist/drift-B5bpkI0i.mjs.map +0 -1
- package/dist/drift-BNa92AK5.mjs +0 -10
- package/dist/fetchStackImports-C-1THPYL.mjs.map +0 -1
- package/dist/generator-Ba-vqyBG.mjs.map +0 -1
- package/dist/import-B0dlwKoQ.mjs.map +0 -1
- package/dist/loader-DAvCKLTT.mjs +0 -518
- package/dist/loader-DAvCKLTT.mjs.map +0 -1
- package/dist/plan-Do5rE-c5.mjs.map +0 -1
package/dist/tamer.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { c as TAMER_OVERLAY_ENV_KEY, f as getDispatchNamespaces, n as materializeTamerResolvable, r as materializeVars, t as materializeCloudflareBindings } from "./normalize-DVSTRZhO.mjs";
|
|
3
|
+
import { basename, dirname, resolve } from "path";
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
import * as readline from "readline/promises";
|
|
6
|
+
|
|
2
7
|
//#region node_modules/zod/v4/core/core.js
|
|
3
8
|
/** A special constant with type `never` */
|
|
4
9
|
const NEVER = Object.freeze({ status: "aborted" });
|
|
@@ -150,7 +155,7 @@ const allowsEval = cached(() => {
|
|
|
150
155
|
return false;
|
|
151
156
|
}
|
|
152
157
|
});
|
|
153
|
-
function isPlainObject(o) {
|
|
158
|
+
function isPlainObject$1(o) {
|
|
154
159
|
if (isObject(o) === false) return false;
|
|
155
160
|
const ctor = o.constructor;
|
|
156
161
|
if (ctor === void 0) return true;
|
|
@@ -161,7 +166,7 @@ function isPlainObject(o) {
|
|
|
161
166
|
return true;
|
|
162
167
|
}
|
|
163
168
|
function shallowClone(o) {
|
|
164
|
-
if (isPlainObject(o)) return { ...o };
|
|
169
|
+
if (isPlainObject$1(o)) return { ...o };
|
|
165
170
|
if (Array.isArray(o)) return [...o];
|
|
166
171
|
return o;
|
|
167
172
|
}
|
|
@@ -242,7 +247,7 @@ function omit(schema, mask) {
|
|
|
242
247
|
}));
|
|
243
248
|
}
|
|
244
249
|
function extend(schema, shape) {
|
|
245
|
-
if (!isPlainObject(shape)) throw new Error("Invalid input to extend: expected a plain object");
|
|
250
|
+
if (!isPlainObject$1(shape)) throw new Error("Invalid input to extend: expected a plain object");
|
|
246
251
|
const checks = schema._zod.def.checks;
|
|
247
252
|
if (checks && checks.length > 0) {
|
|
248
253
|
const existingShape = schema._zod.def.shape;
|
|
@@ -258,7 +263,7 @@ function extend(schema, shape) {
|
|
|
258
263
|
} }));
|
|
259
264
|
}
|
|
260
265
|
function safeExtend(schema, shape) {
|
|
261
|
-
if (!isPlainObject(shape)) throw new Error("Invalid input to safeExtend: expected a plain object");
|
|
266
|
+
if (!isPlainObject$1(shape)) throw new Error("Invalid input to safeExtend: expected a plain object");
|
|
262
267
|
return clone(schema, mergeDefs(schema._zod.def, { get shape() {
|
|
263
268
|
const _shape = {
|
|
264
269
|
...schema._zod.def.shape,
|
|
@@ -1777,7 +1782,7 @@ function mergeValues(a, b) {
|
|
|
1777
1782
|
valid: true,
|
|
1778
1783
|
data: a
|
|
1779
1784
|
};
|
|
1780
|
-
if (isPlainObject(a) && isPlainObject(b)) {
|
|
1785
|
+
if (isPlainObject$1(a) && isPlainObject$1(b)) {
|
|
1781
1786
|
const bKeys = Object.keys(b);
|
|
1782
1787
|
const sharedKeys = Object.keys(a).filter((key) => bKeys.indexOf(key) !== -1);
|
|
1783
1788
|
const newObj = {
|
|
@@ -1853,7 +1858,7 @@ const $ZodRecord = /* @__PURE__ */ $constructor("$ZodRecord", (inst, def) => {
|
|
|
1853
1858
|
$ZodType.init(inst, def);
|
|
1854
1859
|
inst._zod.parse = (payload, ctx) => {
|
|
1855
1860
|
const input = payload.value;
|
|
1856
|
-
if (!isPlainObject(input)) {
|
|
1861
|
+
if (!isPlainObject$1(input)) {
|
|
1857
1862
|
payload.issues.push({
|
|
1858
1863
|
expected: "record",
|
|
1859
1864
|
code: "invalid_type",
|
|
@@ -2723,6 +2728,17 @@ function _array(Class, element, params) {
|
|
|
2723
2728
|
});
|
|
2724
2729
|
}
|
|
2725
2730
|
/* @__NO_SIDE_EFFECTS__ */
|
|
2731
|
+
function _custom(Class, fn, _params) {
|
|
2732
|
+
const norm = normalizeParams(_params);
|
|
2733
|
+
norm.abort ?? (norm.abort = true);
|
|
2734
|
+
return new Class({
|
|
2735
|
+
type: "custom",
|
|
2736
|
+
check: "custom",
|
|
2737
|
+
fn,
|
|
2738
|
+
...norm
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
/* @__NO_SIDE_EFFECTS__ */
|
|
2726
2742
|
function _refine(Class, fn, _params) {
|
|
2727
2743
|
return new Class({
|
|
2728
2744
|
type: "custom",
|
|
@@ -4020,6 +4036,9 @@ const ZodCustom = /* @__PURE__ */ $constructor("ZodCustom", (inst, def) => {
|
|
|
4020
4036
|
ZodType.init(inst, def);
|
|
4021
4037
|
inst._zod.processJSONSchema = (ctx, json, params) => customProcessor(inst, ctx, json, params);
|
|
4022
4038
|
});
|
|
4039
|
+
function custom(fn, _params) {
|
|
4040
|
+
return _custom(ZodCustom, fn ?? (() => true), _params);
|
|
4041
|
+
}
|
|
4023
4042
|
function refine(fn, _params = {}) {
|
|
4024
4043
|
return _refine(ZodCustom, fn, _params);
|
|
4025
4044
|
}
|
|
@@ -4036,198 +4055,4391 @@ function number(params) {
|
|
|
4036
4055
|
}
|
|
4037
4056
|
|
|
4038
4057
|
//#endregion
|
|
4039
|
-
//#region src/
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
}
|
|
4052
|
-
const
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
const
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
out
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
"
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
"
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
const ProvisionTenantArgsSchema = object({
|
|
4105
|
-
env: string().min(1, { error: "env is required" }),
|
|
4106
|
-
product: string().min(1, { error: "product is required" }),
|
|
4107
|
-
workspace: string().min(1, { error: "workspace is required" }),
|
|
4108
|
-
main: string().optional(),
|
|
4109
|
-
artifact_key: string().optional(),
|
|
4110
|
-
module_name: string().optional(),
|
|
4111
|
-
config: string().optional(),
|
|
4112
|
-
compatibility_date: string().optional(),
|
|
4113
|
-
compat_flags: string().optional(),
|
|
4114
|
-
shards: string().optional(),
|
|
4115
|
-
json: boolean().optional()
|
|
4116
|
-
}).refine((d) => !!(d.main || d.artifact_key), { message: "Provide --main <file> or --artifact-key <r2-key> (under tamer-artifacts-{env})" });
|
|
4117
|
-
const DestroyTenantArgsSchema = object({
|
|
4118
|
-
env: string().min(1, { error: "env is required" }),
|
|
4119
|
-
product: string().min(1, { error: "product is required" }),
|
|
4120
|
-
workspace: string().min(1, { error: "workspace is required" }),
|
|
4121
|
-
force: boolean().optional(),
|
|
4122
|
-
confirm_tenant: string().optional(),
|
|
4123
|
-
config: string().optional(),
|
|
4124
|
-
json: boolean().optional()
|
|
4125
|
-
});
|
|
4126
|
-
const DeployArgsSchema = BaseArgsSchema.extend({ dispatch_namespace: string().optional() });
|
|
4127
|
-
const DevArgsSchema = BaseArgsSchema.extend({ all: boolean().optional() });
|
|
4128
|
-
function parseArgs(argv) {
|
|
4129
|
-
const opts = {};
|
|
4130
|
-
for (let i = 0; i < argv.length; i++) {
|
|
4131
|
-
const arg = argv[i];
|
|
4132
|
-
if (arg.startsWith("--")) {
|
|
4133
|
-
const key = arg.slice(2).replace(/-/g, "_");
|
|
4134
|
-
const next = argv[i + 1];
|
|
4135
|
-
if (next && !next.startsWith("--")) {
|
|
4136
|
-
opts[key] = next;
|
|
4137
|
-
i++;
|
|
4138
|
-
} else opts[key] = true;
|
|
4058
|
+
//#region src/core/config/merge-project-overlay.ts
|
|
4059
|
+
/**
|
|
4060
|
+
* Deep-merge `tamer/env/<env>.config.ts` onto `tamer/project.config.ts`.
|
|
4061
|
+
* See docs/design-tamer-project-config.md.
|
|
4062
|
+
*/
|
|
4063
|
+
function isPlainObject(v) {
|
|
4064
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
4065
|
+
}
|
|
4066
|
+
function validateOverlayWorkers(projectWorkers, overlayWorkers) {
|
|
4067
|
+
for (const k of Object.keys(overlayWorkers)) if (!(k in projectWorkers)) throw new Error(`Tamer env overlay: unknown worker key "${k}". Declare "${k}" in tamer/project.config.ts first — overlays may only patch existing workers.`);
|
|
4068
|
+
}
|
|
4069
|
+
function mergeWorkerResources(base, patch) {
|
|
4070
|
+
const out = { ...base };
|
|
4071
|
+
for (const k of Object.keys(patch)) {
|
|
4072
|
+
const pk = patch[k];
|
|
4073
|
+
if (pk !== void 0) out[k] = pk;
|
|
4074
|
+
}
|
|
4075
|
+
return out;
|
|
4076
|
+
}
|
|
4077
|
+
function mergeEnvMap(base, patch) {
|
|
4078
|
+
const out = { ...base };
|
|
4079
|
+
for (const k of Object.keys(patch)) {
|
|
4080
|
+
const b = base[k];
|
|
4081
|
+
const p = patch[k];
|
|
4082
|
+
if (isPlainObject(b) && isPlainObject(p)) out[k] = mergeWorkerConfig(b, p);
|
|
4083
|
+
else out[k] = p;
|
|
4084
|
+
}
|
|
4085
|
+
return out;
|
|
4086
|
+
}
|
|
4087
|
+
function mergeWorkerConfig(base, patch) {
|
|
4088
|
+
const out = { ...base };
|
|
4089
|
+
for (const key of Object.keys(patch)) {
|
|
4090
|
+
const pv = patch[key];
|
|
4091
|
+
if (pv === void 0) continue;
|
|
4092
|
+
if (key === "vars") {
|
|
4093
|
+
const bv = isPlainObject(base.vars) ? base.vars : {};
|
|
4094
|
+
const ov = isPlainObject(patch.vars) ? patch.vars : {};
|
|
4095
|
+
out.vars = {
|
|
4096
|
+
...bv,
|
|
4097
|
+
...ov
|
|
4098
|
+
};
|
|
4099
|
+
continue;
|
|
4100
|
+
}
|
|
4101
|
+
if (key === "tamerRoutes") {
|
|
4102
|
+
out.tamerRoutes = pv;
|
|
4103
|
+
continue;
|
|
4104
|
+
}
|
|
4105
|
+
if (key === "tamerStaleRouteSweepZones") {
|
|
4106
|
+
out.tamerStaleRouteSweepZones = pv;
|
|
4107
|
+
continue;
|
|
4108
|
+
}
|
|
4109
|
+
if (key === "resources") {
|
|
4110
|
+
if (isPlainObject(base.resources) && isPlainObject(pv)) out.resources = mergeWorkerResources(base.resources, pv);
|
|
4111
|
+
else out.resources = pv;
|
|
4112
|
+
continue;
|
|
4113
|
+
}
|
|
4114
|
+
if (key === "env") {
|
|
4115
|
+
if (isPlainObject(base.env) && isPlainObject(pv)) out.env = mergeEnvMap(base.env, pv);
|
|
4116
|
+
else out.env = pv;
|
|
4117
|
+
continue;
|
|
4118
|
+
}
|
|
4119
|
+
if (key === "local") {
|
|
4120
|
+
if (isPlainObject(base.local) && isPlainObject(pv)) out.local = mergeWorkerConfig(base.local, pv);
|
|
4121
|
+
else out.local = pv;
|
|
4122
|
+
continue;
|
|
4139
4123
|
}
|
|
4124
|
+
out[key] = pv;
|
|
4140
4125
|
}
|
|
4141
|
-
return
|
|
4126
|
+
return out;
|
|
4127
|
+
}
|
|
4128
|
+
function mergeWorkersRecord(base, patch) {
|
|
4129
|
+
const out = { ...base };
|
|
4130
|
+
for (const k of Object.keys(patch)) {
|
|
4131
|
+
const b = base[k];
|
|
4132
|
+
const p = patch[k];
|
|
4133
|
+
if (isPlainObject(b) && isPlainObject(p)) out[k] = mergeWorkerConfig(b, p);
|
|
4134
|
+
else out[k] = p;
|
|
4135
|
+
}
|
|
4136
|
+
return out;
|
|
4142
4137
|
}
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4138
|
+
/**
|
|
4139
|
+
* Merge env overlay onto project config (both plain objects from `default` exports).
|
|
4140
|
+
* Does not run {@link materializeCloudflareBindings} — caller does that on the result.
|
|
4141
|
+
*/
|
|
4142
|
+
function mergeProjectOverlay(project, overlay) {
|
|
4143
|
+
const out = { ...project };
|
|
4144
|
+
for (const key of Object.keys(overlay)) {
|
|
4145
|
+
const ov = overlay[key];
|
|
4146
|
+
if (ov === void 0) continue;
|
|
4147
|
+
const pv = project[key];
|
|
4148
|
+
switch (key) {
|
|
4149
|
+
case "tenant":
|
|
4150
|
+
if (isPlainObject(pv) && isPlainObject(ov)) out[key] = {
|
|
4151
|
+
...pv,
|
|
4152
|
+
...ov
|
|
4153
|
+
};
|
|
4154
|
+
else out[key] = ov;
|
|
4155
|
+
break;
|
|
4156
|
+
case "naming":
|
|
4157
|
+
out[key] = ov;
|
|
4158
|
+
break;
|
|
4159
|
+
case "workers":
|
|
4160
|
+
if (!isPlainObject(pv) || !isPlainObject(ov)) {
|
|
4161
|
+
out[key] = ov;
|
|
4162
|
+
break;
|
|
4163
|
+
}
|
|
4164
|
+
validateOverlayWorkers(pv, ov);
|
|
4165
|
+
out[key] = mergeWorkersRecord(pv, ov);
|
|
4166
|
+
break;
|
|
4167
|
+
case "worker":
|
|
4168
|
+
if (isPlainObject(pv) && isPlainObject(ov)) out[key] = mergeWorkerConfig(pv, ov);
|
|
4169
|
+
else out[key] = ov;
|
|
4170
|
+
break;
|
|
4171
|
+
case "outputs":
|
|
4172
|
+
if (isPlainObject(pv) && isPlainObject(ov)) out[key] = {
|
|
4173
|
+
...pv,
|
|
4174
|
+
...ov
|
|
4175
|
+
};
|
|
4176
|
+
else out[key] = ov;
|
|
4177
|
+
break;
|
|
4178
|
+
case "logpushJobs":
|
|
4179
|
+
case "dispatchNamespaces":
|
|
4180
|
+
case "dnsRecords":
|
|
4181
|
+
out[key] = ov;
|
|
4182
|
+
break;
|
|
4183
|
+
default: out[key] = ov;
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
return out;
|
|
4147
4187
|
}
|
|
4148
|
-
|
|
4149
|
-
|
|
4188
|
+
|
|
4189
|
+
//#endregion
|
|
4190
|
+
//#region src/core/config/resolve-config-sources.ts
|
|
4191
|
+
function isProjectFileName(file) {
|
|
4192
|
+
return file === "project.config.ts" || file === "tamer.project.config.ts";
|
|
4150
4193
|
}
|
|
4151
|
-
function
|
|
4152
|
-
const
|
|
4153
|
-
if (
|
|
4154
|
-
return
|
|
4155
|
-
env: parsed.data.env,
|
|
4156
|
-
configPath: parsed.data.config
|
|
4157
|
-
};
|
|
4194
|
+
function projectDirForMergedEntry(absProject) {
|
|
4195
|
+
const dir = dirname(absProject);
|
|
4196
|
+
if (basename(absProject) === "project.config.ts") return dir;
|
|
4197
|
+
return dir;
|
|
4158
4198
|
}
|
|
4159
|
-
function
|
|
4160
|
-
|
|
4161
|
-
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
4162
|
-
return {
|
|
4163
|
-
env: parsed.data.env,
|
|
4164
|
-
addShard: parsed.data.add_shard,
|
|
4165
|
-
configPath: parsed.data.config,
|
|
4166
|
-
planFile: parsed.data.plan,
|
|
4167
|
-
allowStale: parsed.data.allow_stale,
|
|
4168
|
-
rollbackOnFailure: parsed.data.rollback_on_failure,
|
|
4169
|
-
target: parsed.data.target
|
|
4170
|
-
};
|
|
4199
|
+
function overlayPathForNestedLayout(projectDir, env) {
|
|
4200
|
+
return resolve(projectDir, "env", `${env}.config.ts`);
|
|
4171
4201
|
}
|
|
4172
|
-
function
|
|
4173
|
-
|
|
4174
|
-
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
4175
|
-
return {
|
|
4176
|
-
worker: parsed.data.worker,
|
|
4177
|
-
env: parsed.data.env,
|
|
4178
|
-
configPath: parsed.data.config,
|
|
4179
|
-
all: parsed.data.all
|
|
4180
|
-
};
|
|
4202
|
+
function overlayPathForFlatLayout(projectRoot, env) {
|
|
4203
|
+
return resolve(projectRoot, `tamer.env.${env}.ts`);
|
|
4181
4204
|
}
|
|
4182
|
-
function
|
|
4183
|
-
|
|
4184
|
-
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
4185
|
-
return {
|
|
4186
|
-
worker: parsed.data.worker,
|
|
4187
|
-
env: parsed.data.env,
|
|
4188
|
-
configPath: parsed.data.config,
|
|
4189
|
-
dispatchNamespace: parsed.data.dispatch_namespace
|
|
4190
|
-
};
|
|
4205
|
+
function rejectLegacyTamerConfigFile(context) {
|
|
4206
|
+
throw new Error(`${context}: tamer.config.ts is not supported. Move the default export to tamer/project.config.ts and optional per-env overlays to tamer/env/<env>.config.ts (or use tamer.project.config.ts at the repo root with optional tamer.env.<env>.ts).`);
|
|
4191
4207
|
}
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4208
|
+
/**
|
|
4209
|
+
* Resolve which config file(s) to load. See docs/design-tamer-project-config.md.
|
|
4210
|
+
*
|
|
4211
|
+
* - **Merged:** `tamer/project.config.ts` or `tamer.project.config.ts`, plus optional env overlay.
|
|
4212
|
+
* - **Single:** explicit `--config` path to any `.ts` file **except** `tamer.config.ts` (e.g. alternate snapshots in tests).
|
|
4213
|
+
*/
|
|
4214
|
+
function resolveConfigSources(cwd, explicitConfigPath, env) {
|
|
4215
|
+
if (explicitConfigPath) {
|
|
4216
|
+
const abs = resolve(cwd, explicitConfigPath);
|
|
4217
|
+
const file = basename(abs);
|
|
4218
|
+
if (file === "tamer.config.ts") rejectLegacyTamerConfigFile("--config");
|
|
4219
|
+
if (isProjectFileName(file)) {
|
|
4220
|
+
const projectDir = projectDirForMergedEntry(abs);
|
|
4221
|
+
let overlay = null;
|
|
4222
|
+
if (env) if (file === "project.config.ts") {
|
|
4223
|
+
const candidate = overlayPathForNestedLayout(projectDir, env);
|
|
4224
|
+
if (existsSync(candidate)) overlay = candidate;
|
|
4225
|
+
} else {
|
|
4226
|
+
const candidate = overlayPathForFlatLayout(dirname(abs), env);
|
|
4227
|
+
if (existsSync(candidate)) overlay = candidate;
|
|
4228
|
+
}
|
|
4229
|
+
return {
|
|
4230
|
+
mode: "merged",
|
|
4231
|
+
projectPath: abs,
|
|
4232
|
+
overlayPath: overlay
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
return {
|
|
4236
|
+
mode: "single",
|
|
4237
|
+
path: abs
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
const nestedProject = resolve(cwd, "tamer", "project.config.ts");
|
|
4241
|
+
if (existsSync(nestedProject)) {
|
|
4242
|
+
let overlay = null;
|
|
4243
|
+
if (env) {
|
|
4244
|
+
const candidate = overlayPathForNestedLayout(dirname(nestedProject), env);
|
|
4245
|
+
if (existsSync(candidate)) overlay = candidate;
|
|
4246
|
+
}
|
|
4247
|
+
return {
|
|
4248
|
+
mode: "merged",
|
|
4249
|
+
projectPath: nestedProject,
|
|
4250
|
+
overlayPath: overlay
|
|
4251
|
+
};
|
|
4252
|
+
}
|
|
4253
|
+
const flatProject = resolve(cwd, "tamer.project.config.ts");
|
|
4254
|
+
if (existsSync(flatProject)) {
|
|
4255
|
+
let overlay = null;
|
|
4256
|
+
if (env) {
|
|
4257
|
+
const candidate = overlayPathForFlatLayout(cwd, env);
|
|
4258
|
+
if (existsSync(candidate)) overlay = candidate;
|
|
4259
|
+
}
|
|
4260
|
+
return {
|
|
4261
|
+
mode: "merged",
|
|
4262
|
+
projectPath: flatProject,
|
|
4263
|
+
overlayPath: overlay
|
|
4264
|
+
};
|
|
4265
|
+
}
|
|
4266
|
+
if (existsSync(resolve(cwd, "tamer.config.ts"))) rejectLegacyTamerConfigFile("Config discovery");
|
|
4267
|
+
throw new Error(`No Tamer project config under ${cwd}. Create tamer/project.config.ts (or tamer.project.config.ts at the repo root).`);
|
|
4200
4268
|
}
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4269
|
+
|
|
4270
|
+
//#endregion
|
|
4271
|
+
//#region src/core/config/loader.ts
|
|
4272
|
+
const TENANT_SHARD_ROLE_RE = /^[a-z][a-z0-9_-]*$/;
|
|
4273
|
+
const TenantMetaSchema = object({
|
|
4274
|
+
id: string().min(1),
|
|
4275
|
+
name: string().min(1),
|
|
4276
|
+
slug: string().min(1),
|
|
4277
|
+
d1Shards: array(string().min(1).refine((s) => TENANT_SHARD_ROLE_RE.test(s), { error: "tenant.d1Shards entries must be lowercase ASCII (letters, digits, `_`, `-`) and start with a letter" })).optional().refine((arr) => !arr || new Set(arr).size === arr.length, "tenant.d1Shards must not contain duplicate roles"),
|
|
4278
|
+
protectedEnvs: array(string().min(1)).optional(),
|
|
4279
|
+
ephemeralEnvPattern: string().min(1).optional().refine((s) => {
|
|
4280
|
+
if (s == null) return true;
|
|
4281
|
+
try {
|
|
4282
|
+
new RegExp(s);
|
|
4283
|
+
return true;
|
|
4284
|
+
} catch {
|
|
4285
|
+
return false;
|
|
4286
|
+
}
|
|
4287
|
+
}, "tenant.ephemeralEnvPattern must be a valid JavaScript RegExp source string")
|
|
4288
|
+
});
|
|
4289
|
+
const CloudflareNameFnSchema = custom((v) => v === void 0 || typeof v === "function").optional();
|
|
4290
|
+
const D1ResourceConfigSchema = object({
|
|
4291
|
+
logicalName: string().min(1),
|
|
4292
|
+
type: _enum(["single", "sharded"]),
|
|
4293
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4294
|
+
ownership: _enum(["managed", "external"]).optional(),
|
|
4295
|
+
databaseName: string().optional(),
|
|
4296
|
+
binding: string().optional(),
|
|
4297
|
+
migrationsDir: string().optional(),
|
|
4298
|
+
migrationsTable: string().optional(),
|
|
4299
|
+
preserveOnDestroy: boolean().optional()
|
|
4300
|
+
}).refine((d) => d.ownership !== "external" || typeof d.databaseName === "string" && d.databaseName.length > 0, { error: "resources.d1: ownership 'external' requires non-empty databaseName" }).refine((d) => d.ownership !== "external" || d.type === "single", { error: "resources.d1: ownership 'external' only supports type 'single'" });
|
|
4301
|
+
const R2ResourceConfigSchema = object({
|
|
4302
|
+
logicalName: string().min(1),
|
|
4303
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4304
|
+
binding: string().optional()
|
|
4305
|
+
});
|
|
4306
|
+
const KVResourceConfigSchema = object({
|
|
4307
|
+
logicalName: string().min(1),
|
|
4308
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4309
|
+
binding: string().optional()
|
|
4310
|
+
});
|
|
4311
|
+
const QueueResourceConfigSchema = object({
|
|
4312
|
+
logicalName: string().min(1),
|
|
4313
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4314
|
+
binding: string().optional(),
|
|
4315
|
+
consumerOnly: boolean().optional()
|
|
4316
|
+
});
|
|
4317
|
+
const HyperdriveSecretSchema = union([string(), object({ fromEnv: string().min(1) })]);
|
|
4318
|
+
const HyperdriveOriginSchema = object({
|
|
4319
|
+
scheme: _enum([
|
|
4320
|
+
"postgres",
|
|
4321
|
+
"postgresql",
|
|
4322
|
+
"mysql"
|
|
4323
|
+
]),
|
|
4324
|
+
host: string().min(1),
|
|
4325
|
+
port: number$1().optional(),
|
|
4326
|
+
database: string().min(1),
|
|
4327
|
+
user: string().min(1),
|
|
4328
|
+
password: HyperdriveSecretSchema,
|
|
4329
|
+
access_client_id: string().optional(),
|
|
4330
|
+
access_client_secret: HyperdriveSecretSchema.optional()
|
|
4331
|
+
});
|
|
4332
|
+
const HyperdriveResourceConfigSchema = object({
|
|
4333
|
+
logicalName: string().min(1),
|
|
4334
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4335
|
+
binding: string().optional(),
|
|
4336
|
+
origin: HyperdriveOriginSchema,
|
|
4337
|
+
caching: object({
|
|
4338
|
+
disabled: boolean().optional(),
|
|
4339
|
+
max_age: number$1().optional(),
|
|
4340
|
+
stale_while_revalidate: number$1().optional()
|
|
4341
|
+
}).optional(),
|
|
4342
|
+
mtls: object({
|
|
4343
|
+
ca_certificate_id: string().optional(),
|
|
4344
|
+
mtls_certificate_id: string().optional()
|
|
4345
|
+
}).optional(),
|
|
4346
|
+
localConnectionString: string().optional()
|
|
4347
|
+
});
|
|
4348
|
+
const VectorizeResourceConfigSchema = object({
|
|
4349
|
+
logicalName: string().min(1),
|
|
4350
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4351
|
+
binding: string().optional(),
|
|
4352
|
+
dimensions: number$1().int().positive(),
|
|
4353
|
+
metric: _enum([
|
|
4354
|
+
"cosine",
|
|
4355
|
+
"euclidean",
|
|
4356
|
+
"dot-product"
|
|
4357
|
+
]),
|
|
4358
|
+
description: string().optional()
|
|
4359
|
+
});
|
|
4360
|
+
const AIGatewayResourceConfigSchema = object({
|
|
4361
|
+
logicalName: string().min(1),
|
|
4362
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4363
|
+
cacheTtl: number$1().int().nonnegative().optional(),
|
|
4364
|
+
cacheInvalidateOnUpdate: boolean().optional(),
|
|
4365
|
+
collectLogs: boolean().optional(),
|
|
4366
|
+
authentication: boolean().optional(),
|
|
4367
|
+
rateLimitingInterval: number$1().int().nonnegative().optional(),
|
|
4368
|
+
rateLimitingLimit: number$1().int().nonnegative().optional(),
|
|
4369
|
+
rateLimitingTechnique: _enum(["fixed", "sliding"]).optional()
|
|
4370
|
+
});
|
|
4371
|
+
const DispatchNamespaceResourceSchema = object({
|
|
4372
|
+
logicalName: string().min(1),
|
|
4373
|
+
namespace: string().min(1),
|
|
4374
|
+
envSuffix: boolean().optional()
|
|
4375
|
+
});
|
|
4376
|
+
const LogpushJobR2DestinationSchema = object({
|
|
4377
|
+
bucketLogicalName: string().min(1),
|
|
4378
|
+
pathPrefix: string().optional(),
|
|
4379
|
+
accessKeyIdEnv: string().min(1),
|
|
4380
|
+
secretAccessKeyEnv: string().min(1)
|
|
4381
|
+
});
|
|
4382
|
+
const LogpushJobPipelinesIngestSchema = object({
|
|
4383
|
+
streamId: string().min(1),
|
|
4384
|
+
pipelineId: string().min(1),
|
|
4385
|
+
bearerTokenEnv: string().min(1)
|
|
4386
|
+
});
|
|
4387
|
+
const LogpushJobPipelinesAutoSchema = object({
|
|
4388
|
+
catalogBucketLogicalName: string().min(1),
|
|
4389
|
+
tableName: string().min(1),
|
|
4390
|
+
namespace: string().min(1).optional(),
|
|
4391
|
+
tableNameAppendTimestamp: boolean().optional(),
|
|
4392
|
+
sinkRowGroupBytes: number$1().int().positive().optional(),
|
|
4393
|
+
sinkRollingFileSizeBytes: number$1().int().positive().optional(),
|
|
4394
|
+
sinkRollingIntervalSeconds: number$1().int().positive().optional()
|
|
4395
|
+
});
|
|
4396
|
+
const LogpushJobResourceConfigSchema = object({
|
|
4397
|
+
logicalName: string().min(1),
|
|
4398
|
+
dataset: literal("workers_trace_events"),
|
|
4399
|
+
jobName: string().optional(),
|
|
4400
|
+
r2: LogpushJobR2DestinationSchema.optional(),
|
|
4401
|
+
pipelinesIngest: LogpushJobPipelinesIngestSchema.optional(),
|
|
4402
|
+
pipelinesAuto: LogpushJobPipelinesAutoSchema.optional(),
|
|
4403
|
+
destinationConfEnv: string().min(1).optional(),
|
|
4404
|
+
destinationConfFromJobId: number$1().int().positive().optional(),
|
|
4405
|
+
destinationConfFromJobIdEnv: string().min(1).optional(),
|
|
4406
|
+
filter: string().min(1).optional(),
|
|
4407
|
+
fieldNames: array(string()).optional(),
|
|
4408
|
+
enabled: boolean().optional()
|
|
4409
|
+
}).refine((d) => {
|
|
4410
|
+
const hasR2 = Boolean(d.r2);
|
|
4411
|
+
const n = (d.destinationConfEnv ? 1 : 0) + (d.destinationConfFromJobId != null ? 1 : 0) + (d.destinationConfFromJobIdEnv ? 1 : 0) + (d.pipelinesIngest ? 1 : 0) + (d.pipelinesAuto ? 1 : 0);
|
|
4412
|
+
if (hasR2) return n === 0;
|
|
4413
|
+
return n === 1;
|
|
4414
|
+
}, { error: "logpushJobs: set exactly one of r2 | pipelinesIngest | pipelinesAuto | destinationConfEnv | destinationConfFromJobId | destinationConfFromJobIdEnv" }).refine((d) => !(d.destinationConfFromJobId != null && d.destinationConfFromJobIdEnv), { error: "logpushJobs: use only one of destinationConfFromJobId or destinationConfFromJobIdEnv" }).refine((d) => !(d.pipelinesIngest && d.pipelinesAuto), { error: "logpushJobs: use only one of pipelinesIngest or pipelinesAuto" });
|
|
4415
|
+
const DnsRecordResourceConfigSchema = object({
|
|
4416
|
+
logicalName: string().min(1),
|
|
4417
|
+
zoneId: string().min(1),
|
|
4418
|
+
type: _enum([
|
|
4419
|
+
"A",
|
|
4420
|
+
"AAAA",
|
|
4421
|
+
"CNAME",
|
|
4422
|
+
"TXT",
|
|
4423
|
+
"MX",
|
|
4424
|
+
"NS",
|
|
4425
|
+
"CAA",
|
|
4426
|
+
"SRV",
|
|
4427
|
+
"PTR",
|
|
4428
|
+
"HTTPS",
|
|
4429
|
+
"SVCB"
|
|
4430
|
+
]),
|
|
4431
|
+
name: string().min(1),
|
|
4432
|
+
content: string().min(1),
|
|
4433
|
+
ttl: number$1().int().positive().optional(),
|
|
4434
|
+
proxied: boolean().optional(),
|
|
4435
|
+
priority: number$1().int().nonnegative().optional(),
|
|
4436
|
+
comment: string().optional(),
|
|
4437
|
+
skipEnvs: array(string()).optional(),
|
|
4438
|
+
preserveOnDestroy: boolean().optional()
|
|
4439
|
+
});
|
|
4440
|
+
const PipelineResourceConfigSchema = object({
|
|
4441
|
+
logicalName: string().min(1),
|
|
4442
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4443
|
+
sql: string().min(1),
|
|
4444
|
+
binding: string().min(1).optional()
|
|
4445
|
+
});
|
|
4446
|
+
const WorkflowResourceConfigSchema = object({
|
|
4447
|
+
logicalName: string().min(1),
|
|
4448
|
+
cloudflareName: CloudflareNameFnSchema,
|
|
4449
|
+
className: string().min(1),
|
|
4450
|
+
scriptName: string().min(1).optional(),
|
|
4451
|
+
binding: string().min(1).optional(),
|
|
4452
|
+
limits: object({ steps: number$1().int().positive().optional() }).optional()
|
|
4453
|
+
});
|
|
4454
|
+
const SecretsStoreResourceConfigSchema = object({
|
|
4455
|
+
logicalName: string().min(1),
|
|
4456
|
+
cloudflareName: CloudflareNameFnSchema
|
|
4457
|
+
});
|
|
4458
|
+
const SecretsStoreSecretBindingSchema = object({
|
|
4459
|
+
binding: string().min(1),
|
|
4460
|
+
store: string().min(1),
|
|
4461
|
+
secretName: string().min(1)
|
|
4462
|
+
});
|
|
4463
|
+
const WorkerSecretsConfigSchema = object({ required: array(string().min(1)) });
|
|
4464
|
+
const WorkerResourcesSchema = object({
|
|
4465
|
+
d1: array(D1ResourceConfigSchema).optional(),
|
|
4466
|
+
r2: array(R2ResourceConfigSchema).optional(),
|
|
4467
|
+
kv: array(KVResourceConfigSchema).optional(),
|
|
4468
|
+
queues: array(QueueResourceConfigSchema).optional(),
|
|
4469
|
+
hyperdrive: array(HyperdriveResourceConfigSchema).optional(),
|
|
4470
|
+
vectorize: array(VectorizeResourceConfigSchema).optional(),
|
|
4471
|
+
aiGateway: array(AIGatewayResourceConfigSchema).optional(),
|
|
4472
|
+
pipelines: array(PipelineResourceConfigSchema).optional(),
|
|
4473
|
+
workflows: array(WorkflowResourceConfigSchema).optional(),
|
|
4474
|
+
secretsStores: array(SecretsStoreResourceConfigSchema).optional(),
|
|
4475
|
+
secretsStoreSecrets: array(SecretsStoreSecretBindingSchema).optional()
|
|
4476
|
+
});
|
|
4477
|
+
const WranglerRouteSchema = object({
|
|
4478
|
+
pattern: string(),
|
|
4479
|
+
custom_domain: boolean().optional(),
|
|
4480
|
+
zone_name: string().optional(),
|
|
4481
|
+
zone_id: string().optional()
|
|
4482
|
+
});
|
|
4483
|
+
const WranglerAssetsSchema = object({
|
|
4484
|
+
directory: string(),
|
|
4485
|
+
binding: string().optional(),
|
|
4486
|
+
not_found_handling: _enum(["single-page-application", "return-404"]).optional()
|
|
4487
|
+
});
|
|
4488
|
+
const EnvOverrideSchema = object({
|
|
4489
|
+
vars: record(string(), string()).optional(),
|
|
4490
|
+
scriptName: string().optional(),
|
|
4491
|
+
wranglerOutFile: string().optional(),
|
|
4492
|
+
route: union([WranglerRouteSchema, array(WranglerRouteSchema)]).optional(),
|
|
4493
|
+
routes: array(WranglerRouteSchema).optional()
|
|
4494
|
+
}).passthrough();
|
|
4495
|
+
const WorkerConfigSchema = object({
|
|
4496
|
+
path: string().optional(),
|
|
4497
|
+
config: string().optional(),
|
|
4498
|
+
scriptName: string().optional(),
|
|
4499
|
+
wranglerOutFile: string().optional(),
|
|
4500
|
+
dispatchNamespace: string().optional(),
|
|
4501
|
+
main: string().optional(),
|
|
4502
|
+
compatibility_date: string().optional(),
|
|
4503
|
+
compatibility_flags: array(string()).optional(),
|
|
4504
|
+
limits: record(string(), union([number$1(), string()])).optional(),
|
|
4505
|
+
workers_dev: boolean().optional(),
|
|
4506
|
+
preview_urls: boolean().optional(),
|
|
4507
|
+
resources: WorkerResourcesSchema.optional(),
|
|
4508
|
+
secrets: WorkerSecretsConfigSchema.optional(),
|
|
4509
|
+
alias: record(string(), string()).optional(),
|
|
4510
|
+
vars: record(string(), string()).optional(),
|
|
4511
|
+
local: EnvOverrideSchema.optional(),
|
|
4512
|
+
env: record(string(), EnvOverrideSchema).optional(),
|
|
4513
|
+
assets: WranglerAssetsSchema.optional(),
|
|
4514
|
+
route: union([WranglerRouteSchema, array(WranglerRouteSchema)]).optional(),
|
|
4515
|
+
routes: array(WranglerRouteSchema).optional()
|
|
4516
|
+
}).passthrough();
|
|
4517
|
+
const CfiConfigSchema = object({
|
|
4518
|
+
tenant: TenantMetaSchema,
|
|
4519
|
+
account_id: string().optional(),
|
|
4520
|
+
compatibility_date: string().optional(),
|
|
4521
|
+
naming: any().optional(),
|
|
4522
|
+
dispatchNamespaces: array(DispatchNamespaceResourceSchema).optional(),
|
|
4523
|
+
dnsRecords: array(DnsRecordResourceConfigSchema).optional(),
|
|
4524
|
+
logpushJobs: array(LogpushJobResourceConfigSchema).optional(),
|
|
4525
|
+
stack: object({
|
|
4526
|
+
name: string().regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, { error: "stack.name must match /^[a-zA-Z][a-zA-Z0-9_-]*$/ (CloudFormation-style identifier)" }).optional(),
|
|
4527
|
+
description: string().min(1).optional()
|
|
4528
|
+
}).optional(),
|
|
4529
|
+
outputs: record(string().regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, { error: "outputs key must match /^[a-zA-Z][a-zA-Z0-9_-]*$/ (CloudFormation-style identifier)" }), string().min(1)).optional(),
|
|
4530
|
+
worker: WorkerConfigSchema.optional(),
|
|
4531
|
+
workers: record(string(), WorkerConfigSchema).optional()
|
|
4532
|
+
}).refine((data) => (data.worker ?? data.workers) && !(data.worker && data.workers), { error: "Must have either worker or workers, not both" });
|
|
4533
|
+
async function loadConfig(configPath, options = {}) {
|
|
4534
|
+
const sources = resolveConfigSources(options.cwd ?? process.cwd(), configPath, options.env);
|
|
4535
|
+
let raw;
|
|
4536
|
+
if (sources.mode === "single") {
|
|
4537
|
+
raw = (await import(sources.path)).default;
|
|
4538
|
+
if (!raw) throw new Error(`No default export in ${sources.path}`);
|
|
4539
|
+
} else {
|
|
4540
|
+
const projectRaw = (await import(sources.projectPath)).default;
|
|
4541
|
+
if (!projectRaw || typeof projectRaw !== "object") throw new Error(`No default export in ${sources.projectPath}`);
|
|
4542
|
+
let merged = projectRaw;
|
|
4543
|
+
if (sources.overlayPath) {
|
|
4544
|
+
const overlayRaw = (await import(sources.overlayPath)).default;
|
|
4545
|
+
if (!overlayRaw || typeof overlayRaw !== "object") throw new Error(`Env overlay must default-export an object: ${sources.overlayPath}`);
|
|
4546
|
+
const overlayObj = overlayRaw;
|
|
4547
|
+
const declaredEnv = overlayObj[TAMER_OVERLAY_ENV_KEY];
|
|
4548
|
+
if (declaredEnv !== void 0) {
|
|
4549
|
+
if (typeof declaredEnv !== "string") throw new Error(`Env overlay ${sources.overlayPath}: ${TAMER_OVERLAY_ENV_KEY} must be a string`);
|
|
4550
|
+
if (options.env !== declaredEnv) throw new Error(`Env overlay ${sources.overlayPath} sets ${TAMER_OVERLAY_ENV_KEY} "${declaredEnv}" but this load uses --env "${options.env ?? "(none)"}".`);
|
|
4551
|
+
}
|
|
4552
|
+
const { [TAMER_OVERLAY_ENV_KEY]: _strip, ...overlayRest } = overlayObj;
|
|
4553
|
+
merged = mergeProjectOverlay(merged, overlayRest);
|
|
4554
|
+
}
|
|
4555
|
+
raw = merged;
|
|
4556
|
+
}
|
|
4557
|
+
const parsed = CfiConfigSchema.safeParse(materializeCloudflareBindings(raw));
|
|
4558
|
+
if (!parsed.success) throw new Error(`Invalid Tamer project config: ${parsed.error.message}`);
|
|
4559
|
+
return parsed.data;
|
|
4560
|
+
}
|
|
4561
|
+
async function getWorkers(config$1, baseDir) {
|
|
4562
|
+
const cwd = baseDir ?? process.cwd();
|
|
4563
|
+
if ("worker" in config$1 && config$1.worker) return [["default", await resolveWorkerConfigRef(config$1.worker, cwd)]];
|
|
4564
|
+
if ("workers" in config$1 && config$1.workers) {
|
|
4565
|
+
const entries = [];
|
|
4566
|
+
for (const [key, wc] of Object.entries(config$1.workers)) entries.push([key, await resolveWorkerConfigRef(wc, cwd)]);
|
|
4567
|
+
return entries;
|
|
4568
|
+
}
|
|
4569
|
+
return [];
|
|
4209
4570
|
}
|
|
4210
|
-
function
|
|
4211
|
-
|
|
4212
|
-
|
|
4571
|
+
async function resolveWorkerConfigRef(wc, baseDir) {
|
|
4572
|
+
if (!wc.config) return wc;
|
|
4573
|
+
const configPath = resolve(baseDir, wc.config);
|
|
4574
|
+
const loaded = (await import(configPath)).default;
|
|
4575
|
+
if (!loaded) throw new Error(`No default export in ${configPath}`);
|
|
4213
4576
|
return {
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4577
|
+
...loaded,
|
|
4578
|
+
path: wc.path,
|
|
4579
|
+
config: wc.config
|
|
4217
4580
|
};
|
|
4218
4581
|
}
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4582
|
+
|
|
4583
|
+
//#endregion
|
|
4584
|
+
//#region src/core/cloudflareEnv.ts
|
|
4585
|
+
/**
|
|
4586
|
+
* Wrangler-aligned Cloudflare credentials (same names as `wrangler` CLI).
|
|
4587
|
+
* @see https://developers.cloudflare.com/workers/wrangler/system-environment-variables/
|
|
4588
|
+
*/
|
|
4589
|
+
function cloudflareAccountIdFromEnv() {
|
|
4590
|
+
return process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
4228
4591
|
}
|
|
4229
|
-
function
|
|
4230
|
-
|
|
4592
|
+
function cloudflareApiTokenFromEnv() {
|
|
4593
|
+
return process.env.CLOUDFLARE_API_TOKEN ?? "";
|
|
4594
|
+
}
|
|
4595
|
+
|
|
4596
|
+
//#endregion
|
|
4597
|
+
//#region src/core/api/pipelinesV1PathId.ts
|
|
4598
|
+
/**
|
|
4599
|
+
* Pipelines v1 path segments (streams, sinks, SQL pipelines) expect **stream_id**
|
|
4600
|
+
* / **sink_id** as **exactly 32 lower-case hex characters** (no hyphens). The API
|
|
4601
|
+
* returns 400 if the URL contains a hyphenated UUID (36 chars) — see
|
|
4602
|
+
* `stream_id: String must contain exactly 32 character(s)`.
|
|
4603
|
+
*
|
|
4604
|
+
* State and list APIs may return ids with or without hyphens; we normalize to 32-hex
|
|
4605
|
+
* for GET/DELETE paths.
|
|
4606
|
+
*/
|
|
4607
|
+
function pipelinesV1IdForPath(id) {
|
|
4608
|
+
const c = id.trim().toLowerCase().replace(/-/g, "");
|
|
4609
|
+
if (!/^[0-9a-f]{32}$/.test(c)) return id.trim();
|
|
4610
|
+
return c;
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
//#endregion
|
|
4614
|
+
//#region src/core/api/CFApiClient.ts
|
|
4615
|
+
const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
4616
|
+
/**
|
|
4617
|
+
* Renders the Cloudflare API `errors` / `messages` array into a single string
|
|
4618
|
+
* (message, error code, documentation URL, JSON pointer) for operator-friendly logs.
|
|
4619
|
+
*/
|
|
4620
|
+
function describeCloudflareErrorItem(e) {
|
|
4621
|
+
if (e == null) return "null";
|
|
4622
|
+
if (typeof e === "string") return e;
|
|
4623
|
+
if (typeof e !== "object") return String(e);
|
|
4624
|
+
const o = e;
|
|
4625
|
+
const parts = [];
|
|
4626
|
+
if (typeof o.message === "string" && o.message) parts.push(o.message);
|
|
4627
|
+
if (typeof o.code === "number") parts.push(`[code: ${o.code}]`);
|
|
4628
|
+
if (typeof o.code === "string" && o.code) parts.push(`[code: ${o.code}]`);
|
|
4629
|
+
if (typeof o.documentation_url === "string" && o.documentation_url) parts.push(o.documentation_url);
|
|
4630
|
+
const src = o.source;
|
|
4631
|
+
if (src && typeof src === "object" && "pointer" in src) {
|
|
4632
|
+
const p = src.pointer;
|
|
4633
|
+
if (typeof p === "string" && p) parts.push(`(field: ${p})`);
|
|
4634
|
+
}
|
|
4635
|
+
if (Array.isArray(o.error_chain) && o.error_chain.length > 0) parts.push(`[chain: ${o.error_chain.map(describeCloudflareErrorItem).join(" → ")}]`);
|
|
4636
|
+
if (parts.length) return parts.join(" ");
|
|
4637
|
+
try {
|
|
4638
|
+
return JSON.stringify(e);
|
|
4639
|
+
} catch {
|
|
4640
|
+
return String(e);
|
|
4641
|
+
}
|
|
4642
|
+
}
|
|
4643
|
+
function formatCloudflareErrorBody(errors, messages, fallback) {
|
|
4644
|
+
const errList = formatCloudflareList(errors) || fallback;
|
|
4645
|
+
const msgList = formatCloudflareList(messages);
|
|
4646
|
+
if (msgList) return `${errList} — messages: ${msgList}`;
|
|
4647
|
+
return errList;
|
|
4648
|
+
}
|
|
4649
|
+
function formatCloudflareList(v) {
|
|
4650
|
+
if (!Array.isArray(v) || v.length === 0) return "";
|
|
4651
|
+
return v.map(describeCloudflareErrorItem).join(" | ");
|
|
4652
|
+
}
|
|
4653
|
+
var CFApiClient = class {
|
|
4654
|
+
accountId;
|
|
4655
|
+
apiToken;
|
|
4656
|
+
constructor(accountId, apiToken) {
|
|
4657
|
+
this.accountId = accountId;
|
|
4658
|
+
this.apiToken = apiToken ?? cloudflareApiTokenFromEnv();
|
|
4659
|
+
if (!this.apiToken) throw new Error("CLOUDFLARE_API_TOKEN is required");
|
|
4660
|
+
}
|
|
4661
|
+
getAccountId() {
|
|
4662
|
+
return this.accountId;
|
|
4663
|
+
}
|
|
4664
|
+
/** Lightweight account read — validates token + account scope. */
|
|
4665
|
+
async accountRead() {
|
|
4666
|
+
return (await this.request(`/accounts/${this.accountId}`)).result;
|
|
4667
|
+
}
|
|
4668
|
+
/**
|
|
4669
|
+
* Get a single Worker script by name (account-scoped, **not** dispatch).
|
|
4670
|
+
* Returns `undefined` when Cloudflare responds 404 so callers can treat it
|
|
4671
|
+
* as "not deployed" without try/catch on the message.
|
|
4672
|
+
*
|
|
4673
|
+
* @see https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/get/
|
|
4674
|
+
*/
|
|
4675
|
+
async workersScriptGet(scriptName) {
|
|
4676
|
+
const url = `${CF_API_BASE}/accounts/${this.accountId}/workers/scripts/${encodeURIComponent(scriptName)}`;
|
|
4677
|
+
const res = await fetch(url, { headers: {
|
|
4678
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
4679
|
+
"Content-Type": "application/json"
|
|
4680
|
+
} });
|
|
4681
|
+
if (res.status === 404) return void 0;
|
|
4682
|
+
const data = await res.json();
|
|
4683
|
+
if (!res.ok) {
|
|
4684
|
+
const msg = data?.errors?.map((e) => e.message).join("; ") ?? res.statusText;
|
|
4685
|
+
throw new Error(`CF API error (workers script get): ${msg}`);
|
|
4686
|
+
}
|
|
4687
|
+
return data.result;
|
|
4688
|
+
}
|
|
4689
|
+
/**
|
|
4690
|
+
* Upsert a plain-text secret binding on a Worker script.
|
|
4691
|
+
* @see https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/secrets/methods/update/
|
|
4692
|
+
*/
|
|
4693
|
+
async workersSecretPut(scriptName, secret) {
|
|
4694
|
+
return (await this.request(`/accounts/${this.accountId}/workers/scripts/${encodeURIComponent(scriptName)}/secrets`, {
|
|
4695
|
+
method: "PUT",
|
|
4696
|
+
body: JSON.stringify({
|
|
4697
|
+
name: secret.name,
|
|
4698
|
+
text: secret.text,
|
|
4699
|
+
type: "secret_text"
|
|
4700
|
+
})
|
|
4701
|
+
})).result;
|
|
4702
|
+
}
|
|
4703
|
+
/**
|
|
4704
|
+
* List secret binding names on a Worker script. Values are write-only and
|
|
4705
|
+
* are not returned by the Cloudflare API.
|
|
4706
|
+
* @see https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/secrets/methods/list/
|
|
4707
|
+
*/
|
|
4708
|
+
async workersSecretsList(scriptName) {
|
|
4709
|
+
return ((await this.request(`/accounts/${this.accountId}/workers/scripts/${encodeURIComponent(scriptName)}/secrets`)).result ?? []).map((s) => s.name);
|
|
4710
|
+
}
|
|
4711
|
+
/**
|
|
4712
|
+
* Remove a secret binding from a Worker script.
|
|
4713
|
+
* @see https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/secrets/methods/delete/
|
|
4714
|
+
*/
|
|
4715
|
+
async workersSecretDelete(scriptName, name) {
|
|
4716
|
+
await this.request(`/accounts/${this.accountId}/workers/scripts/${encodeURIComponent(scriptName)}/secrets/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
4717
|
+
}
|
|
4718
|
+
async request(path, options = {}) {
|
|
4719
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
4720
|
+
const url = `${CF_API_BASE}${path}`;
|
|
4721
|
+
const res = await fetch(url, {
|
|
4722
|
+
...options,
|
|
4723
|
+
headers: {
|
|
4724
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
4725
|
+
"Content-Type": "application/json",
|
|
4726
|
+
...options.headers
|
|
4727
|
+
}
|
|
4728
|
+
});
|
|
4729
|
+
let parsed;
|
|
4730
|
+
try {
|
|
4731
|
+
parsed = await res.json();
|
|
4732
|
+
} catch {
|
|
4733
|
+
throw new Error(`CF API error: ${method} \`${path}\` → HTTP ${res.status} (response was not valid JSON)`);
|
|
4734
|
+
}
|
|
4735
|
+
/** Some endpoints (e.g. R2 catalog disable) respond `200` with literal JSON `null`. */
|
|
4736
|
+
const data = parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
4737
|
+
const httpError = !res.ok;
|
|
4738
|
+
const bodySuccessFalse = data.success === false;
|
|
4739
|
+
if (httpError || bodySuccessFalse) {
|
|
4740
|
+
const detail = formatCloudflareErrorBody(data.errors, data.messages, res.statusText || "no error array in body");
|
|
4741
|
+
throw new Error(`CF API error: ${method} \`${path}\` → HTTP ${res.status}. ${detail}`);
|
|
4742
|
+
}
|
|
4743
|
+
return data;
|
|
4744
|
+
}
|
|
4745
|
+
async d1ListAll() {
|
|
4746
|
+
const all = [];
|
|
4747
|
+
let page = 1;
|
|
4748
|
+
const perPage = 100;
|
|
4749
|
+
while (true) {
|
|
4750
|
+
const data = await this.request(`/accounts/${this.accountId}/d1/database?per_page=${perPage}&page=${page}`);
|
|
4751
|
+
all.push(...data.result);
|
|
4752
|
+
const info = data.result_info;
|
|
4753
|
+
if (!info || info.count < perPage) break;
|
|
4754
|
+
page++;
|
|
4755
|
+
}
|
|
4756
|
+
return all;
|
|
4757
|
+
}
|
|
4758
|
+
async r2ListAll() {
|
|
4759
|
+
const all = [];
|
|
4760
|
+
const perPage = 100;
|
|
4761
|
+
let cursor;
|
|
4762
|
+
while (true) {
|
|
4763
|
+
const qs = new URLSearchParams({ per_page: String(perPage) });
|
|
4764
|
+
if (cursor) qs.set("cursor", cursor);
|
|
4765
|
+
const data = await this.request(`/accounts/${this.accountId}/r2/buckets?${qs.toString()}`);
|
|
4766
|
+
const buckets = data.result?.buckets ?? [];
|
|
4767
|
+
all.push(...buckets.map((b) => ({
|
|
4768
|
+
name: b.name ?? "",
|
|
4769
|
+
creation_date: b.creation_date ?? ""
|
|
4770
|
+
})));
|
|
4771
|
+
const next = data.result_info?.cursor;
|
|
4772
|
+
if (!next) break;
|
|
4773
|
+
cursor = next;
|
|
4774
|
+
}
|
|
4775
|
+
return all;
|
|
4776
|
+
}
|
|
4777
|
+
async kvListAll() {
|
|
4778
|
+
const all = [];
|
|
4779
|
+
let page = 1;
|
|
4780
|
+
const perPage = 100;
|
|
4781
|
+
while (true) {
|
|
4782
|
+
const data = await this.request(`/accounts/${this.accountId}/storage/kv/namespaces?per_page=${perPage}&page=${page}`);
|
|
4783
|
+
all.push(...data.result);
|
|
4784
|
+
const info = data.result_info;
|
|
4785
|
+
if (!info || info.count < perPage) break;
|
|
4786
|
+
page++;
|
|
4787
|
+
}
|
|
4788
|
+
return all;
|
|
4789
|
+
}
|
|
4790
|
+
async d1Create(name) {
|
|
4791
|
+
return (await this.request(`/accounts/${this.accountId}/d1/database`, {
|
|
4792
|
+
method: "POST",
|
|
4793
|
+
body: JSON.stringify({ name })
|
|
4794
|
+
})).result;
|
|
4795
|
+
}
|
|
4796
|
+
async r2Create(name, location = "auto") {
|
|
4797
|
+
await this.request(`/accounts/${this.accountId}/r2/buckets`, {
|
|
4798
|
+
method: "POST",
|
|
4799
|
+
body: JSON.stringify({
|
|
4800
|
+
name,
|
|
4801
|
+
location
|
|
4802
|
+
})
|
|
4803
|
+
});
|
|
4804
|
+
}
|
|
4805
|
+
async kvCreate(title) {
|
|
4806
|
+
return (await this.request(`/accounts/${this.accountId}/storage/kv/namespaces`, {
|
|
4807
|
+
method: "POST",
|
|
4808
|
+
body: JSON.stringify({ title })
|
|
4809
|
+
})).result;
|
|
4810
|
+
}
|
|
4811
|
+
async d1Delete(databaseId) {
|
|
4812
|
+
await this.request(`/accounts/${this.accountId}/d1/database/${databaseId}`, { method: "DELETE" });
|
|
4813
|
+
}
|
|
4814
|
+
/**
|
|
4815
|
+
* Run SQL against a D1 database (HTTP API).
|
|
4816
|
+
* @see https://developers.cloudflare.com/d1/build-with-d1/rest-api/
|
|
4817
|
+
*/
|
|
4818
|
+
async d1Query(databaseId, sql, params = []) {
|
|
4819
|
+
return { rows: (await this.request(`/accounts/${this.accountId}/d1/database/${databaseId}/query`, {
|
|
4820
|
+
method: "POST",
|
|
4821
|
+
body: JSON.stringify({
|
|
4822
|
+
sql,
|
|
4823
|
+
params
|
|
4824
|
+
})
|
|
4825
|
+
})).result?.[0]?.results ?? [] };
|
|
4826
|
+
}
|
|
4827
|
+
async r2Delete(bucketName) {
|
|
4828
|
+
await this.request(`/accounts/${this.accountId}/r2/buckets/${bucketName}`, { method: "DELETE" });
|
|
4829
|
+
}
|
|
4830
|
+
/**
|
|
4831
|
+
* Download an object from R2 (HTTP API).
|
|
4832
|
+
* @see https://developers.cloudflare.com/api/resources/r2/subresources/buckets/subresources/objects/methods/get/
|
|
4833
|
+
*/
|
|
4834
|
+
async r2GetObject(bucketName, objectKey) {
|
|
4835
|
+
const pathEncoded = objectKey.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
4836
|
+
const url = `${CF_API_BASE}/accounts/${this.accountId}/r2/buckets/${encodeURIComponent(bucketName)}/objects/${pathEncoded}`;
|
|
4837
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${this.apiToken}` } });
|
|
4838
|
+
if (!res.ok) {
|
|
4839
|
+
let errMsg = res.statusText;
|
|
4840
|
+
try {
|
|
4841
|
+
errMsg = (await res.json())?.errors?.map((e) => e.message).join("; ") ?? errMsg;
|
|
4842
|
+
} catch {}
|
|
4843
|
+
throw new Error(`CF API error (R2 get): ${errMsg}`);
|
|
4844
|
+
}
|
|
4845
|
+
return res.arrayBuffer();
|
|
4846
|
+
}
|
|
4847
|
+
async kvDelete(namespaceId) {
|
|
4848
|
+
await this.request(`/accounts/${this.accountId}/storage/kv/namespaces/${namespaceId}`, { method: "DELETE" });
|
|
4849
|
+
}
|
|
4850
|
+
/**
|
|
4851
|
+
* List every Cloudflare Queue in the account.
|
|
4852
|
+
* @see https://developers.cloudflare.com/api/resources/queues/methods/list/
|
|
4853
|
+
*/
|
|
4854
|
+
async queuesListAll() {
|
|
4855
|
+
const all = [];
|
|
4856
|
+
let page = 1;
|
|
4857
|
+
const perPage = 100;
|
|
4858
|
+
while (true) {
|
|
4859
|
+
const batch = (await this.request(`/accounts/${this.accountId}/queues?per_page=${perPage}&page=${page}`)).result ?? [];
|
|
4860
|
+
all.push(...batch);
|
|
4861
|
+
if (batch.length < perPage) break;
|
|
4862
|
+
page++;
|
|
4863
|
+
}
|
|
4864
|
+
return all;
|
|
4865
|
+
}
|
|
4866
|
+
/**
|
|
4867
|
+
* Create a new Cloudflare Queue.
|
|
4868
|
+
* @see https://developers.cloudflare.com/api/resources/queues/methods/create/
|
|
4869
|
+
*/
|
|
4870
|
+
async queueCreate(queueName) {
|
|
4871
|
+
return (await this.request(`/accounts/${this.accountId}/queues`, {
|
|
4872
|
+
method: "POST",
|
|
4873
|
+
body: JSON.stringify({ queue_name: queueName })
|
|
4874
|
+
})).result;
|
|
4875
|
+
}
|
|
4876
|
+
/**
|
|
4877
|
+
* Delete a Cloudflare Queue by id.
|
|
4878
|
+
* @see https://developers.cloudflare.com/api/resources/queues/methods/delete/
|
|
4879
|
+
*/
|
|
4880
|
+
async queueDelete(queueId) {
|
|
4881
|
+
await this.request(`/accounts/${this.accountId}/queues/${encodeURIComponent(queueId)}`, { method: "DELETE" });
|
|
4882
|
+
}
|
|
4883
|
+
/**
|
|
4884
|
+
* List every Hyperdrive config in the account.
|
|
4885
|
+
* @see https://developers.cloudflare.com/api/resources/hyperdrive/subresources/configs/methods/list/
|
|
4886
|
+
*/
|
|
4887
|
+
async hyperdriveListAll() {
|
|
4888
|
+
return (await this.request(`/accounts/${this.accountId}/hyperdrive/configs`)).result ?? [];
|
|
4889
|
+
}
|
|
4890
|
+
/**
|
|
4891
|
+
* Create a Hyperdrive config.
|
|
4892
|
+
* @see https://developers.cloudflare.com/api/resources/hyperdrive/subresources/configs/methods/create/
|
|
4893
|
+
*/
|
|
4894
|
+
async hyperdriveCreate(body) {
|
|
4895
|
+
return (await this.request(`/accounts/${this.accountId}/hyperdrive/configs`, {
|
|
4896
|
+
method: "POST",
|
|
4897
|
+
body: JSON.stringify(body)
|
|
4898
|
+
})).result;
|
|
4899
|
+
}
|
|
4900
|
+
/**
|
|
4901
|
+
* Patch an existing Hyperdrive configuration in place. Cloudflare accepts
|
|
4902
|
+
* partial updates here (true `PATCH`), so callers may send only the
|
|
4903
|
+
* fields that drifted. Origin patches must include the full origin
|
|
4904
|
+
* payload (the password is write-only and never returned, so Tamer
|
|
4905
|
+
* reads it back from the resource config every apply).
|
|
4906
|
+
* @see https://developers.cloudflare.com/api/resources/hyperdrive/subresources/configs/methods/edit/
|
|
4907
|
+
*/
|
|
4908
|
+
async hyperdrivePatch(configId, body) {
|
|
4909
|
+
await this.request(`/accounts/${this.accountId}/hyperdrive/configs/${encodeURIComponent(configId)}`, {
|
|
4910
|
+
method: "PATCH",
|
|
4911
|
+
body: JSON.stringify(body)
|
|
4912
|
+
});
|
|
4913
|
+
}
|
|
4914
|
+
/**
|
|
4915
|
+
* Delete a Hyperdrive config by id.
|
|
4916
|
+
* @see https://developers.cloudflare.com/api/resources/hyperdrive/subresources/configs/methods/delete/
|
|
4917
|
+
*/
|
|
4918
|
+
async hyperdriveDelete(configId) {
|
|
4919
|
+
await this.request(`/accounts/${this.accountId}/hyperdrive/configs/${encodeURIComponent(configId)}`, { method: "DELETE" });
|
|
4920
|
+
}
|
|
4921
|
+
/**
|
|
4922
|
+
* List every Vectorize index in the account (v2 storage subsystem).
|
|
4923
|
+
* @see https://developers.cloudflare.com/api/resources/vectorize/subresources/indexes/methods/list/
|
|
4924
|
+
*/
|
|
4925
|
+
async vectorizeListAll() {
|
|
4926
|
+
return (await this.request(`/accounts/${this.accountId}/vectorize/v2/indexes`)).result ?? [];
|
|
4927
|
+
}
|
|
4928
|
+
/**
|
|
4929
|
+
* Create a Vectorize v2 index. `dimensions` and `metric` are immutable.
|
|
4930
|
+
* @see https://developers.cloudflare.com/api/resources/vectorize/subresources/indexes/methods/create/
|
|
4931
|
+
*/
|
|
4932
|
+
async vectorizeCreate(body) {
|
|
4933
|
+
return (await this.request(`/accounts/${this.accountId}/vectorize/v2/indexes`, {
|
|
4934
|
+
method: "POST",
|
|
4935
|
+
body: JSON.stringify(body)
|
|
4936
|
+
})).result;
|
|
4937
|
+
}
|
|
4938
|
+
/**
|
|
4939
|
+
* Delete a Vectorize index by name.
|
|
4940
|
+
* @see https://developers.cloudflare.com/api/resources/vectorize/subresources/indexes/methods/delete/
|
|
4941
|
+
*/
|
|
4942
|
+
async vectorizeDelete(indexName) {
|
|
4943
|
+
await this.request(`/accounts/${this.accountId}/vectorize/v2/indexes/${encodeURIComponent(indexName)}`, { method: "DELETE" });
|
|
4944
|
+
}
|
|
4945
|
+
/**
|
|
4946
|
+
* List every AI Gateway in the account.
|
|
4947
|
+
* @see https://developers.cloudflare.com/api/resources/ai_gateway/methods/list/
|
|
4948
|
+
*/
|
|
4949
|
+
async aiGatewayListAll() {
|
|
4950
|
+
return (await this.request(`/accounts/${this.accountId}/ai-gateway/gateways?per_page=100`)).result ?? [];
|
|
4951
|
+
}
|
|
4952
|
+
/**
|
|
4953
|
+
* Create an AI Gateway. The gateway `id` is user-supplied and acts as both
|
|
4954
|
+
* primary key and routing slug under `https://gateway.ai.cloudflare.com/`.
|
|
4955
|
+
* @see https://developers.cloudflare.com/api/resources/ai_gateway/methods/create/
|
|
4956
|
+
*/
|
|
4957
|
+
async aiGatewayCreate(body) {
|
|
4958
|
+
return (await this.request(`/accounts/${this.accountId}/ai-gateway/gateways`, {
|
|
4959
|
+
method: "POST",
|
|
4960
|
+
body: JSON.stringify(body)
|
|
4961
|
+
})).result;
|
|
4962
|
+
}
|
|
4963
|
+
/**
|
|
4964
|
+
* Update an existing AI Gateway in place. The Cloudflare API uses `PUT`
|
|
4965
|
+
* (not PATCH) and is full-replace on the listed fields, so callers must
|
|
4966
|
+
* supply the complete desired state — Tamer's `apply` reads the recorded
|
|
4967
|
+
* state row and merges declared overrides before calling this.
|
|
4968
|
+
* @see https://developers.cloudflare.com/api/resources/ai_gateway/methods/update/
|
|
4969
|
+
*/
|
|
4970
|
+
async aiGatewayUpdate(gatewayId, body) {
|
|
4971
|
+
await this.request(`/accounts/${this.accountId}/ai-gateway/gateways/${encodeURIComponent(gatewayId)}`, {
|
|
4972
|
+
method: "PUT",
|
|
4973
|
+
body: JSON.stringify(body)
|
|
4974
|
+
});
|
|
4975
|
+
}
|
|
4976
|
+
/**
|
|
4977
|
+
* Delete an AI Gateway by id.
|
|
4978
|
+
* @see https://developers.cloudflare.com/api/resources/ai_gateway/methods/delete/
|
|
4979
|
+
*/
|
|
4980
|
+
async aiGatewayDelete(gatewayId) {
|
|
4981
|
+
await this.request(`/accounts/${this.accountId}/ai-gateway/gateways/${encodeURIComponent(gatewayId)}`, { method: "DELETE" });
|
|
4982
|
+
}
|
|
4983
|
+
/**
|
|
4984
|
+
* List every Pipeline in the account (paginated, 100/page). Uses the V1
|
|
4985
|
+
* SQL pipelines endpoint; the deprecated `/accounts/{id}/pipelines`
|
|
4986
|
+
* (HTTP/binding sources) is not used.
|
|
4987
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/methods/list_v1/
|
|
4988
|
+
*/
|
|
4989
|
+
async pipelineListAll() {
|
|
4990
|
+
const all = [];
|
|
4991
|
+
let page = 1;
|
|
4992
|
+
const perPage = 100;
|
|
4993
|
+
while (true) {
|
|
4994
|
+
const batch = (await this.request(`/accounts/${this.accountId}/pipelines/v1/pipelines?per_page=${perPage}&page=${page}`)).result ?? [];
|
|
4995
|
+
all.push(...batch);
|
|
4996
|
+
if (batch.length < perPage) break;
|
|
4997
|
+
page += 1;
|
|
4998
|
+
}
|
|
4999
|
+
return all;
|
|
5000
|
+
}
|
|
5001
|
+
/**
|
|
5002
|
+
* Create a Pipeline. Server assigns the `id`; `name` is user-supplied
|
|
5003
|
+
* and uniquely identifies the pipeline within the account.
|
|
5004
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/methods/create_v1/
|
|
5005
|
+
*/
|
|
5006
|
+
async pipelineCreate(body) {
|
|
5007
|
+
return (await this.request(`/accounts/${this.accountId}/pipelines/v1/pipelines`, {
|
|
5008
|
+
method: "POST",
|
|
5009
|
+
body: JSON.stringify(body)
|
|
5010
|
+
})).result;
|
|
5011
|
+
}
|
|
5012
|
+
/**
|
|
5013
|
+
* Delete a Pipeline by server-assigned id.
|
|
5014
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/methods/delete_v1/
|
|
5015
|
+
*/
|
|
5016
|
+
async pipelineDelete(pipelineId) {
|
|
5017
|
+
const pathId = pipelinesV1IdForPath(pipelineId);
|
|
5018
|
+
await this.request(`/accounts/${this.accountId}/pipelines/v1/pipelines/${encodeURIComponent(pathId)}`, { method: "DELETE" });
|
|
5019
|
+
}
|
|
5020
|
+
/**
|
|
5021
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/subresources/streams/methods/list/
|
|
5022
|
+
*/
|
|
5023
|
+
async pipelinesV1StreamListAll() {
|
|
5024
|
+
const all = [];
|
|
5025
|
+
let page = 1;
|
|
5026
|
+
const perPage = 100;
|
|
5027
|
+
while (true) {
|
|
5028
|
+
const batch = (await this.request(`/accounts/${this.accountId}/pipelines/v1/streams?per_page=${perPage}&page=${page}`)).result ?? [];
|
|
5029
|
+
all.push(...batch);
|
|
5030
|
+
if (batch.length < perPage) break;
|
|
5031
|
+
page += 1;
|
|
5032
|
+
}
|
|
5033
|
+
return all;
|
|
5034
|
+
}
|
|
5035
|
+
/**
|
|
5036
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/subresources/streams/methods/create/
|
|
5037
|
+
*/
|
|
5038
|
+
async pipelinesV1StreamCreate(body) {
|
|
5039
|
+
return (await this.request(`/accounts/${this.accountId}/pipelines/v1/streams`, {
|
|
5040
|
+
method: "POST",
|
|
5041
|
+
body: JSON.stringify(body)
|
|
5042
|
+
})).result;
|
|
5043
|
+
}
|
|
5044
|
+
/**
|
|
5045
|
+
* Path id: 32 lower-case hex chars (no hyphens). The dashboard issues a
|
|
5046
|
+
* plain `DELETE` with no query string; pass `{ force: true }` only if the API
|
|
5047
|
+
* docs require it for your case.
|
|
5048
|
+
*
|
|
5049
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/subresources/streams/methods/delete/
|
|
5050
|
+
*/
|
|
5051
|
+
async pipelinesV1StreamDelete(streamId, query) {
|
|
5052
|
+
const pathId = pipelinesV1IdForPath(streamId);
|
|
5053
|
+
const qs = new URLSearchParams();
|
|
5054
|
+
if (query?.force) qs.set("force", "true");
|
|
5055
|
+
const tail = qs.toString() ? `?${qs.toString()}` : "";
|
|
5056
|
+
await this.request(`/accounts/${this.accountId}/pipelines/v1/streams/${encodeURIComponent(pathId)}${tail}`, { method: "DELETE" });
|
|
5057
|
+
}
|
|
5058
|
+
/**
|
|
5059
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/subresources/sinks/methods/list/
|
|
5060
|
+
*/
|
|
5061
|
+
async pipelinesV1SinkListAll() {
|
|
5062
|
+
const all = [];
|
|
5063
|
+
let page = 1;
|
|
5064
|
+
const perPage = 100;
|
|
5065
|
+
while (true) {
|
|
5066
|
+
const batch = (await this.request(`/accounts/${this.accountId}/pipelines/v1/sinks?per_page=${perPage}&page=${page}`)).result ?? [];
|
|
5067
|
+
all.push(...batch);
|
|
5068
|
+
if (batch.length < perPage) break;
|
|
5069
|
+
page += 1;
|
|
5070
|
+
}
|
|
5071
|
+
return all;
|
|
5072
|
+
}
|
|
5073
|
+
/**
|
|
5074
|
+
* R2 Data Catalog sink `config` includes `table_name` and `namespace` as
|
|
5075
|
+
* Cloudflare has registered them (may differ from the name sent at create).
|
|
5076
|
+
*
|
|
5077
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/subresources/sinks/methods/get/
|
|
5078
|
+
*/
|
|
5079
|
+
async pipelinesV1SinkGet(sinkId) {
|
|
5080
|
+
const pathId = pipelinesV1IdForPath(sinkId);
|
|
5081
|
+
return (await this.request(`/accounts/${this.accountId}/pipelines/v1/sinks/${encodeURIComponent(pathId)}`)).result;
|
|
5082
|
+
}
|
|
5083
|
+
/**
|
|
5084
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/subresources/sinks/methods/create/
|
|
5085
|
+
*/
|
|
5086
|
+
async pipelinesV1SinkCreate(body) {
|
|
5087
|
+
return (await this.request(`/accounts/${this.accountId}/pipelines/v1/sinks`, {
|
|
5088
|
+
method: "POST",
|
|
5089
|
+
body: JSON.stringify(body)
|
|
5090
|
+
})).result;
|
|
5091
|
+
}
|
|
5092
|
+
/**
|
|
5093
|
+
* Path id: 32 lower-case hex chars (no hyphens). Optional `{ force: true }`
|
|
5094
|
+
* matches `?force=true` when the product requires it (e.g. some sink types).
|
|
5095
|
+
*
|
|
5096
|
+
* @see https://developers.cloudflare.com/api/resources/pipelines/subresources/sinks/methods/delete/
|
|
5097
|
+
*/
|
|
5098
|
+
async pipelinesV1SinkDelete(sinkId, query) {
|
|
5099
|
+
const pathId = pipelinesV1IdForPath(sinkId);
|
|
5100
|
+
const qs = new URLSearchParams();
|
|
5101
|
+
if (query?.force) qs.set("force", "true");
|
|
5102
|
+
const tail = qs.toString() ? `?${qs.toString()}` : "";
|
|
5103
|
+
await this.request(`/accounts/${this.accountId}/pipelines/v1/sinks/${encodeURIComponent(pathId)}${tail}`, { method: "DELETE" });
|
|
5104
|
+
}
|
|
5105
|
+
/**
|
|
5106
|
+
* @see https://developers.cloudflare.com/api/resources/r2_data_catalog/methods/list/
|
|
5107
|
+
*/
|
|
5108
|
+
async r2DataCatalogList() {
|
|
5109
|
+
return { warehouses: (await this.request(`/accounts/${this.accountId}/r2-catalog`)).result?.warehouses ?? [] };
|
|
5110
|
+
}
|
|
5111
|
+
/**
|
|
5112
|
+
* @see https://developers.cloudflare.com/api/resources/r2_data_catalog/methods/enable/
|
|
5113
|
+
*/
|
|
5114
|
+
async r2DataCatalogEnable(bucketName) {
|
|
5115
|
+
await this.request(`/accounts/${this.accountId}/r2-catalog/${encodeURIComponent(bucketName)}/enable`, { method: "POST" });
|
|
5116
|
+
}
|
|
5117
|
+
/**
|
|
5118
|
+
* @see https://developers.cloudflare.com/api/resources/r2_data_catalog/methods/update/
|
|
5119
|
+
* (Store catalog credentials)
|
|
5120
|
+
*/
|
|
5121
|
+
async r2DataCatalogStoreCredential(bucketName, token) {
|
|
5122
|
+
await this.request(`/accounts/${this.accountId}/r2-catalog/${encodeURIComponent(bucketName)}/credential`, {
|
|
5123
|
+
method: "POST",
|
|
5124
|
+
body: JSON.stringify({ token })
|
|
5125
|
+
});
|
|
5126
|
+
}
|
|
5127
|
+
/**
|
|
5128
|
+
* Deactivates the R2 Data Catalog for a bucket (metadata files remain in R2
|
|
5129
|
+
* until the bucket is emptied or objects are removed). Next `apply` will
|
|
5130
|
+
* re-enable the catalog as part of `pipelinesAuto` when needed.
|
|
5131
|
+
*
|
|
5132
|
+
* @see https://developers.cloudflare.com/api/resources/r2_data_catalog/methods/disable/
|
|
5133
|
+
*/
|
|
5134
|
+
async r2DataCatalogDisable(bucketName) {
|
|
5135
|
+
await this.request(`/accounts/${this.accountId}/r2-catalog/${encodeURIComponent(bucketName)}/disable`, { method: "POST" });
|
|
5136
|
+
}
|
|
5137
|
+
/**
|
|
5138
|
+
* List every Workflow registered on the account (paginated, 100/page).
|
|
5139
|
+
* Cloudflare returns the configured class + script binding plus
|
|
5140
|
+
* aggregate instance counts; we only consume the registration metadata.
|
|
5141
|
+
* @see https://developers.cloudflare.com/api/resources/workflows/methods/list/
|
|
5142
|
+
*/
|
|
5143
|
+
async workflowListAll() {
|
|
5144
|
+
const all = [];
|
|
5145
|
+
let page = 1;
|
|
5146
|
+
while (true) {
|
|
5147
|
+
const data = await this.request(`/accounts/${this.accountId}/workflows?page=${page}&per_page=100`);
|
|
5148
|
+
const batch = data.result ?? [];
|
|
5149
|
+
all.push(...batch);
|
|
5150
|
+
const totalPages = data.result_info?.total_pages ?? 1;
|
|
5151
|
+
if (page >= totalPages || batch.length === 0) break;
|
|
5152
|
+
page += 1;
|
|
5153
|
+
}
|
|
5154
|
+
return all;
|
|
5155
|
+
}
|
|
5156
|
+
/**
|
|
5157
|
+
* Create or update a Workflow registration. Cloudflare's `PUT` is upsert:
|
|
5158
|
+
* if `workflow_name` exists it patches `class_name` / `script_name` /
|
|
5159
|
+
* `limits`, otherwise it creates a new one. The bound class must already
|
|
5160
|
+
* be deployed in `script_name`'s code (PUT against an unknown class
|
|
5161
|
+
* succeeds at registration time but fails at first instance create).
|
|
5162
|
+
* @see https://developers.cloudflare.com/api/resources/workflows/methods/update/
|
|
5163
|
+
*/
|
|
5164
|
+
async workflowUpsert(name, body) {
|
|
5165
|
+
return (await this.request(`/accounts/${this.accountId}/workflows/${encodeURIComponent(name)}`, {
|
|
5166
|
+
method: "PUT",
|
|
5167
|
+
body: JSON.stringify(body)
|
|
5168
|
+
})).result;
|
|
5169
|
+
}
|
|
5170
|
+
/**
|
|
5171
|
+
* Delete a Workflow registration by name. Per Cloudflare's docs this only
|
|
5172
|
+
* removes the workflow itself — the worker that hosts the class is
|
|
5173
|
+
* untouched and existing in-flight instances continue to run.
|
|
5174
|
+
* @see https://developers.cloudflare.com/api/resources/workflows/methods/delete/
|
|
5175
|
+
*/
|
|
5176
|
+
async workflowDelete(name) {
|
|
5177
|
+
await this.request(`/accounts/${this.accountId}/workflows/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
5178
|
+
}
|
|
5179
|
+
/**
|
|
5180
|
+
* List every Secrets Store store on the account (paginated, 100/page).
|
|
5181
|
+
* Stores are account-scoped containers; their internal secret entries
|
|
5182
|
+
* are listed via {@link secretsStoreSecretListAll}.
|
|
5183
|
+
* @see https://developers.cloudflare.com/api/resources/secrets_store/subresources/stores/methods/list/
|
|
5184
|
+
*/
|
|
5185
|
+
async secretsStoreListAll() {
|
|
5186
|
+
const all = [];
|
|
5187
|
+
let page = 1;
|
|
5188
|
+
while (true) {
|
|
5189
|
+
const data = await this.request(`/accounts/${this.accountId}/secrets_store/stores?page=${page}&per_page=100`);
|
|
5190
|
+
const batch = data.result ?? [];
|
|
5191
|
+
all.push(...batch);
|
|
5192
|
+
const totalPages = data.result_info?.total_pages ?? 1;
|
|
5193
|
+
if (page >= totalPages || batch.length === 0) break;
|
|
5194
|
+
page += 1;
|
|
5195
|
+
}
|
|
5196
|
+
return all;
|
|
5197
|
+
}
|
|
5198
|
+
/**
|
|
5199
|
+
* Create a new Secrets Store. Per the API a store name uniquely identifies
|
|
5200
|
+
* the container within an account but is **not** itself a primary key —
|
|
5201
|
+
* the assigned `id` is. Tamer enforces uniqueness via the derived
|
|
5202
|
+
* `sec-{logical}-t-{tenantId}-{env}` name and short-circuits to the
|
|
5203
|
+
* existing id when it finds a match in `secretsStoreListAll`.
|
|
5204
|
+
* @see https://developers.cloudflare.com/api/resources/secrets_store/subresources/stores/methods/create/
|
|
5205
|
+
*/
|
|
5206
|
+
async secretsStoreCreate(name) {
|
|
5207
|
+
return (await this.request(`/accounts/${this.accountId}/secrets_store/stores`, {
|
|
5208
|
+
method: "POST",
|
|
5209
|
+
body: JSON.stringify({ name })
|
|
5210
|
+
})).result;
|
|
5211
|
+
}
|
|
5212
|
+
/**
|
|
5213
|
+
* Delete a Secrets Store by id. Cloudflare cascades the deletion to all
|
|
5214
|
+
* secrets inside the store, so callers should ensure no live worker is
|
|
5215
|
+
* still binding into this store before invoking.
|
|
5216
|
+
* @see https://developers.cloudflare.com/api/resources/secrets_store/subresources/stores/methods/delete/
|
|
5217
|
+
*/
|
|
5218
|
+
async secretsStoreDelete(storeId) {
|
|
5219
|
+
await this.request(`/accounts/${this.accountId}/secrets_store/stores/${encodeURIComponent(storeId)}`, { method: "DELETE" });
|
|
5220
|
+
}
|
|
5221
|
+
async dispatchNamespaceListAll() {
|
|
5222
|
+
return (await this.request(`/accounts/${this.accountId}/workers/dispatch/namespaces`)).result ?? [];
|
|
5223
|
+
}
|
|
5224
|
+
async dispatchNamespaceCreate(namespaceName) {
|
|
5225
|
+
await this.request(`/accounts/${this.accountId}/workers/dispatch/namespaces`, {
|
|
5226
|
+
method: "POST",
|
|
5227
|
+
body: JSON.stringify({ name: namespaceName })
|
|
5228
|
+
});
|
|
5229
|
+
}
|
|
5230
|
+
async dispatchNamespaceDelete(namespaceName) {
|
|
5231
|
+
await this.request(`/accounts/${this.accountId}/workers/dispatch/namespaces/${encodeURIComponent(namespaceName)}`, { method: "DELETE" });
|
|
5232
|
+
}
|
|
5233
|
+
/**
|
|
5234
|
+
* Upload a user Worker module to a Workers for Platforms dispatch namespace (multipart).
|
|
5235
|
+
* @see https://developers.cloudflare.com/api/operations/namespace-worker-script-upload-worker-module
|
|
5236
|
+
*/
|
|
5237
|
+
async dispatchNamespaceScriptPut(dispatchNamespace, scriptName, formData) {
|
|
5238
|
+
const url = `${CF_API_BASE}/accounts/${this.accountId}/workers/dispatch/namespaces/${encodeURIComponent(dispatchNamespace)}/scripts/${encodeURIComponent(scriptName)}`;
|
|
5239
|
+
const res = await fetch(url, {
|
|
5240
|
+
method: "PUT",
|
|
5241
|
+
headers: { Authorization: `Bearer ${this.apiToken}` },
|
|
5242
|
+
body: formData
|
|
5243
|
+
});
|
|
5244
|
+
const data = await res.json();
|
|
5245
|
+
if (!res.ok) {
|
|
5246
|
+
const errMsg = data?.errors?.map((e) => e.message).join("; ") ?? res.statusText;
|
|
5247
|
+
throw new Error(`CF API error: ${errMsg}`);
|
|
5248
|
+
}
|
|
5249
|
+
}
|
|
5250
|
+
/**
|
|
5251
|
+
* List every script inside a dispatch namespace.
|
|
5252
|
+
* @see https://developers.cloudflare.com/api/resources/workers_for_platforms/subresources/dispatch/subresources/namespaces/subresources/scripts/methods/list/
|
|
5253
|
+
*/
|
|
5254
|
+
async dispatchNamespaceScriptList(dispatchNamespace) {
|
|
5255
|
+
return (await this.request(`/accounts/${this.accountId}/workers/dispatch/namespaces/${encodeURIComponent(dispatchNamespace)}/scripts`)).result ?? [];
|
|
5256
|
+
}
|
|
5257
|
+
/**
|
|
5258
|
+
* Remove a script from a Workers for Platforms dispatch namespace.
|
|
5259
|
+
* @see https://developers.cloudflare.com/api/resources/workers_for_platforms/subresources/dispatch/subresources/namespaces/subresources/scripts/methods/delete/
|
|
5260
|
+
*/
|
|
5261
|
+
async dispatchNamespaceScriptDelete(dispatchNamespace, scriptName, options) {
|
|
5262
|
+
const q = options?.force ? "?force=true" : "";
|
|
5263
|
+
const url = `${CF_API_BASE}/accounts/${this.accountId}/workers/dispatch/namespaces/${encodeURIComponent(dispatchNamespace)}/scripts/${encodeURIComponent(scriptName)}${q}`;
|
|
5264
|
+
const res = await fetch(url, {
|
|
5265
|
+
method: "DELETE",
|
|
5266
|
+
headers: { Authorization: `Bearer ${this.apiToken}` }
|
|
5267
|
+
});
|
|
5268
|
+
if (!res.ok) {
|
|
5269
|
+
let errMsg = res.statusText;
|
|
5270
|
+
try {
|
|
5271
|
+
errMsg = (await res.json())?.errors?.map((e) => e.message).join("; ") ?? errMsg;
|
|
5272
|
+
} catch {}
|
|
5273
|
+
throw new Error(`CF API error: ${errMsg}`);
|
|
5274
|
+
}
|
|
5275
|
+
}
|
|
5276
|
+
/**
|
|
5277
|
+
* @see https://developers.cloudflare.com/api/resources/workers/subresources/routes/methods/list/
|
|
5278
|
+
*/
|
|
5279
|
+
async zoneWorkerRoutesList(zoneId) {
|
|
5280
|
+
return (await this.request(`/zones/${zoneId}/workers/routes`)).result ?? [];
|
|
5281
|
+
}
|
|
5282
|
+
/**
|
|
5283
|
+
* @see https://developers.cloudflare.com/api/resources/workers/subresources/routes/methods/create/
|
|
5284
|
+
*/
|
|
5285
|
+
async zoneWorkerRouteCreate(zoneId, body) {
|
|
5286
|
+
return (await this.request(`/zones/${zoneId}/workers/routes`, {
|
|
5287
|
+
method: "POST",
|
|
5288
|
+
body: JSON.stringify(body)
|
|
5289
|
+
})).result;
|
|
5290
|
+
}
|
|
5291
|
+
/**
|
|
5292
|
+
* @see https://developers.cloudflare.com/api/resources/workers/subresources/routes/methods/delete/
|
|
5293
|
+
*/
|
|
5294
|
+
async zoneWorkerRouteDelete(zoneId, routeId) {
|
|
5295
|
+
await this.request(`/zones/${zoneId}/workers/routes/${encodeURIComponent(routeId)}`, { method: "DELETE" });
|
|
5296
|
+
}
|
|
5297
|
+
/**
|
|
5298
|
+
* List every DNS record on a zone (paginated, 100/page). Returns the
|
|
5299
|
+
* subset of fields Tamer cares about — the full Cloudflare object also
|
|
5300
|
+
* carries `meta`, `proxiable`, `created_on`, `modified_on`, etc., none of
|
|
5301
|
+
* which we persist in state.
|
|
5302
|
+
*
|
|
5303
|
+
* @see https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/
|
|
5304
|
+
*/
|
|
5305
|
+
async zoneDnsRecordListAll(zoneId) {
|
|
5306
|
+
const all = [];
|
|
5307
|
+
let page = 1;
|
|
5308
|
+
while (true) {
|
|
5309
|
+
const data = await this.request(`/zones/${zoneId}/dns_records?page=${page}&per_page=100`);
|
|
5310
|
+
const batch = data.result ?? [];
|
|
5311
|
+
all.push(...batch);
|
|
5312
|
+
const totalPages = data.result_info?.total_pages ?? 1;
|
|
5313
|
+
if (page >= totalPages || batch.length === 0) break;
|
|
5314
|
+
page += 1;
|
|
5315
|
+
}
|
|
5316
|
+
return all;
|
|
5317
|
+
}
|
|
5318
|
+
/**
|
|
5319
|
+
* Create a DNS record on a zone. The Cloudflare API rejects duplicate
|
|
5320
|
+
* `(type, name, content)` triples for record types where that combination
|
|
5321
|
+
* is meaningful (CNAME, A, AAAA), so callers should pre-check via
|
|
5322
|
+
* {@link zoneDnsRecordListAll}.
|
|
5323
|
+
*
|
|
5324
|
+
* @see https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/
|
|
5325
|
+
*/
|
|
5326
|
+
async zoneDnsRecordCreate(zoneId, body) {
|
|
5327
|
+
return (await this.request(`/zones/${zoneId}/dns_records`, {
|
|
5328
|
+
method: "POST",
|
|
5329
|
+
body: JSON.stringify(body)
|
|
5330
|
+
})).result;
|
|
5331
|
+
}
|
|
5332
|
+
/**
|
|
5333
|
+
* Patch a DNS record in place. Cloudflare's PATCH endpoint accepts any
|
|
5334
|
+
* subset of mutable fields (`content`, `ttl`, `proxied`, `priority`,
|
|
5335
|
+
* `comment`) but rejects `type` changes — Tamer falls back to
|
|
5336
|
+
* delete-and-recreate when the type drifts.
|
|
5337
|
+
*
|
|
5338
|
+
* @see https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
|
|
5339
|
+
*/
|
|
5340
|
+
async zoneDnsRecordPatch(zoneId, recordId, body) {
|
|
5341
|
+
await this.request(`/zones/${zoneId}/dns_records/${encodeURIComponent(recordId)}`, {
|
|
5342
|
+
method: "PATCH",
|
|
5343
|
+
body: JSON.stringify(body)
|
|
5344
|
+
});
|
|
5345
|
+
}
|
|
5346
|
+
/**
|
|
5347
|
+
* @see https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/
|
|
5348
|
+
*/
|
|
5349
|
+
async zoneDnsRecordDelete(zoneId, recordId) {
|
|
5350
|
+
await this.request(`/zones/${zoneId}/dns_records/${encodeURIComponent(recordId)}`, { method: "DELETE" });
|
|
5351
|
+
}
|
|
5352
|
+
/**
|
|
5353
|
+
* Look up zones by exact name.
|
|
5354
|
+
*
|
|
5355
|
+
* Account-scoped tokens with `Zone Read` see every zone under the account.
|
|
5356
|
+
* Returns an array because Cloudflare allows the same name across accounts;
|
|
5357
|
+
* the caller normally takes `[0]`.
|
|
5358
|
+
*
|
|
5359
|
+
* @see https://developers.cloudflare.com/api/operations/zones-get
|
|
5360
|
+
*/
|
|
5361
|
+
async zonesListByName(name) {
|
|
5362
|
+
const qs = new URLSearchParams({
|
|
5363
|
+
name,
|
|
5364
|
+
"account.id": this.accountId,
|
|
5365
|
+
per_page: "50"
|
|
5366
|
+
});
|
|
5367
|
+
return (await this.request(`/zones?${qs.toString()}`)).result ?? [];
|
|
5368
|
+
}
|
|
5369
|
+
/**
|
|
5370
|
+
* List account-scoped Logpush jobs (includes `workers_trace_events`).
|
|
5371
|
+
* @see https://developers.cloudflare.com/api/resources/logpush/subresources/jobs/methods/list/
|
|
5372
|
+
*/
|
|
5373
|
+
async logpushAccountJobsList() {
|
|
5374
|
+
return (await this.request(`/accounts/${this.accountId}/logpush/jobs`)).result ?? [];
|
|
5375
|
+
}
|
|
5376
|
+
/**
|
|
5377
|
+
* Fetch one account-scoped Logpush job (includes `destination_conf`).
|
|
5378
|
+
* @see https://developers.cloudflare.com/api/resources/logpush/subresources/jobs/methods/get/
|
|
5379
|
+
*/
|
|
5380
|
+
async logpushAccountJobGet(jobId) {
|
|
5381
|
+
const data = await this.request(`/accounts/${this.accountId}/logpush/jobs/${jobId}`);
|
|
5382
|
+
if (!data.result?.id) throw new Error(`Logpush job GET ${jobId}: missing result (job may not exist on this account)`);
|
|
5383
|
+
return data.result;
|
|
5384
|
+
}
|
|
5385
|
+
/**
|
|
5386
|
+
* Create an account Logpush job. Caller supplies `destination_conf` and dataset.
|
|
5387
|
+
* @see https://developers.cloudflare.com/api/resources/logpush/subresources/jobs/methods/create/
|
|
5388
|
+
*/
|
|
5389
|
+
async logpushAccountJobCreate(body) {
|
|
5390
|
+
return (await this.request(`/accounts/${this.accountId}/logpush/jobs`, {
|
|
5391
|
+
method: "POST",
|
|
5392
|
+
body: JSON.stringify(body)
|
|
5393
|
+
})).result;
|
|
5394
|
+
}
|
|
5395
|
+
/**
|
|
5396
|
+
* Update mutable Logpush job fields in place.
|
|
5397
|
+
* @see https://developers.cloudflare.com/api/resources/logpush/subresources/jobs/methods/update/
|
|
5398
|
+
*/
|
|
5399
|
+
async logpushAccountJobUpdate(jobId, body) {
|
|
5400
|
+
await this.request(`/accounts/${this.accountId}/logpush/jobs/${jobId}`, {
|
|
5401
|
+
method: "PUT",
|
|
5402
|
+
body: JSON.stringify(body)
|
|
5403
|
+
});
|
|
5404
|
+
}
|
|
5405
|
+
/**
|
|
5406
|
+
* Delete a Logpush job by numeric id.
|
|
5407
|
+
* @see https://developers.cloudflare.com/api/resources/logpush/subresources/jobs/methods/delete/
|
|
5408
|
+
*/
|
|
5409
|
+
async logpushAccountJobDelete(jobId) {
|
|
5410
|
+
await this.request(`/accounts/${this.accountId}/logpush/jobs/${jobId}`, { method: "DELETE" });
|
|
5411
|
+
}
|
|
5412
|
+
/**
|
|
5413
|
+
* List all permission groups for account-owned API tokens (paginated).
|
|
5414
|
+
* @see https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/subresources/permission_groups/methods/list/
|
|
5415
|
+
*/
|
|
5416
|
+
async accountTokenPermissionGroupsListAll() {
|
|
5417
|
+
const all = [];
|
|
5418
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5419
|
+
let page = 1;
|
|
5420
|
+
const perPage = 100;
|
|
5421
|
+
/** Failsafe: pagination must always terminate even if the API omits `total_count` or repeats pages. */
|
|
5422
|
+
const maxPage = 500;
|
|
5423
|
+
while (true) {
|
|
5424
|
+
if (page > maxPage) throw new Error(`Account token permission groups: exceeded ${maxPage} pages (partial count ${all.length}) — refusing to paginate further (check Cloudflare API or file a bug)`);
|
|
5425
|
+
const data = await this.request(`/accounts/${this.accountId}/tokens/permission_groups?per_page=${perPage}&page=${page}`);
|
|
5426
|
+
const batch = data.result ?? [];
|
|
5427
|
+
if (batch.length === 0) break;
|
|
5428
|
+
const newRows = batch.filter((g) => g.id && !seen.has(g.id));
|
|
5429
|
+
for (const g of newRows) seen.add(g.id);
|
|
5430
|
+
if (newRows.length === 0) break;
|
|
5431
|
+
all.push(...newRows);
|
|
5432
|
+
if (batch.length < perPage) break;
|
|
5433
|
+
const total = data.result_info?.total_count;
|
|
5434
|
+
if (typeof total === "number" && all.length >= total) break;
|
|
5435
|
+
page++;
|
|
5436
|
+
}
|
|
5437
|
+
return all;
|
|
5438
|
+
}
|
|
5439
|
+
/**
|
|
5440
|
+
* List all account-owned API tokens (paginated).
|
|
5441
|
+
* @see https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/methods/list/
|
|
5442
|
+
*/
|
|
5443
|
+
async accountTokenListAll() {
|
|
5444
|
+
const all = [];
|
|
5445
|
+
let page = 1;
|
|
5446
|
+
const perPage = 50;
|
|
5447
|
+
const maxPage = 500;
|
|
5448
|
+
while (page <= maxPage) {
|
|
5449
|
+
const data = await this.request(`/accounts/${this.accountId}/tokens?per_page=${perPage}&page=${page}`);
|
|
5450
|
+
const batch = data.result ?? [];
|
|
5451
|
+
all.push(...batch);
|
|
5452
|
+
const total = data.result_info?.total_count;
|
|
5453
|
+
if (typeof total === "number" && all.length >= total) break;
|
|
5454
|
+
if (batch.length < perPage) break;
|
|
5455
|
+
page += 1;
|
|
5456
|
+
}
|
|
5457
|
+
return all;
|
|
5458
|
+
}
|
|
5459
|
+
/**
|
|
5460
|
+
* Create an account-owned API token. `result.value` is only returned on create.
|
|
5461
|
+
* @see https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/methods/create/
|
|
5462
|
+
*/
|
|
5463
|
+
async accountTokenCreate(body) {
|
|
5464
|
+
return (await this.request(`/accounts/${this.accountId}/tokens`, {
|
|
5465
|
+
method: "POST",
|
|
5466
|
+
body: JSON.stringify(body)
|
|
5467
|
+
})).result;
|
|
5468
|
+
}
|
|
5469
|
+
/**
|
|
5470
|
+
* Delete (revoke) an account-owned API token.
|
|
5471
|
+
* @see https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/methods/delete/
|
|
5472
|
+
*/
|
|
5473
|
+
async accountTokenDelete(tokenId) {
|
|
5474
|
+
await this.request(`/accounts/${this.accountId}/tokens/${encodeURIComponent(tokenId)}`, { method: "DELETE" });
|
|
5475
|
+
}
|
|
5476
|
+
};
|
|
5477
|
+
|
|
5478
|
+
//#endregion
|
|
5479
|
+
//#region src/core/secrets/secretsDb.ts
|
|
5480
|
+
/** Max audit rows retained per secret name (oldest dropped on insert). */
|
|
5481
|
+
const SECRET_HISTORY_CAP = 50;
|
|
5482
|
+
/** Cloudflare D1 database for encrypted secrets (`tamer-secrets-dev`, …). */
|
|
5483
|
+
function tamerSecretsDatabaseName(env) {
|
|
5484
|
+
return `tamer-secrets-${env}`;
|
|
5485
|
+
}
|
|
5486
|
+
async function findTamerSecretsDatabaseUuid(api, env) {
|
|
5487
|
+
const name = tamerSecretsDatabaseName(env);
|
|
5488
|
+
return (await api.d1ListAll()).find((d) => d.name === name)?.uuid;
|
|
5489
|
+
}
|
|
5490
|
+
const SECRETS_TABLE_DDL = `CREATE TABLE IF NOT EXISTS secrets (
|
|
5491
|
+
name TEXT PRIMARY KEY,
|
|
5492
|
+
ciphertext BLOB NOT NULL,
|
|
5493
|
+
iv BLOB NOT NULL,
|
|
5494
|
+
wrapped_dek BLOB NOT NULL,
|
|
5495
|
+
dek_iv BLOB NOT NULL,
|
|
5496
|
+
value_hash TEXT NOT NULL,
|
|
5497
|
+
updated_at TEXT NOT NULL,
|
|
5498
|
+
updated_by TEXT
|
|
5499
|
+
)`;
|
|
5500
|
+
const SECRET_HISTORY_TABLE_DDL = `CREATE TABLE IF NOT EXISTS secret_history (
|
|
5501
|
+
name TEXT NOT NULL,
|
|
5502
|
+
value_hash TEXT NOT NULL,
|
|
5503
|
+
updated_at TEXT NOT NULL,
|
|
5504
|
+
updated_by TEXT
|
|
5505
|
+
)`;
|
|
5506
|
+
/**
|
|
5507
|
+
* Create `tamer-secrets-{env}` if missing and ensure `secrets` / `secret_history`
|
|
5508
|
+
* tables. Idempotent — safe to call on every bootstrap or first vault touch.
|
|
5509
|
+
*/
|
|
5510
|
+
async function ensureTamerSecretsDatabase(api, env) {
|
|
5511
|
+
let uuid$1 = await findTamerSecretsDatabaseUuid(api, env);
|
|
5512
|
+
if (!uuid$1) uuid$1 = (await api.d1Create(tamerSecretsDatabaseName(env))).uuid;
|
|
5513
|
+
await api.d1Query(uuid$1, SECRETS_TABLE_DDL);
|
|
5514
|
+
await api.d1Query(uuid$1, SECRET_HISTORY_TABLE_DDL);
|
|
5515
|
+
return uuid$1;
|
|
5516
|
+
}
|
|
5517
|
+
|
|
5518
|
+
//#endregion
|
|
5519
|
+
//#region src/core/secrets/masterKey.ts
|
|
5520
|
+
const MASTER_KEY_BYTE_LENGTH = 32;
|
|
5521
|
+
const MASTER_KEY_ENV_PREFIX = "TAMER_SECRETS_KEY_";
|
|
5522
|
+
/** Env var name for the per-env master key (e.g. `TAMER_SECRETS_KEY_dev`). */
|
|
5523
|
+
function masterKeyEnvVarName(env) {
|
|
5524
|
+
return `${MASTER_KEY_ENV_PREFIX}${env}`;
|
|
5525
|
+
}
|
|
5526
|
+
/** Generate a fresh 256-bit master key as base64 (print once, store externally). */
|
|
5527
|
+
function generateMasterKey() {
|
|
5528
|
+
return bytesToBase64(crypto.getRandomValues(new Uint8Array(MASTER_KEY_BYTE_LENGTH)));
|
|
5529
|
+
}
|
|
5530
|
+
/** Parse and validate a base64 master key string into raw key material. */
|
|
5531
|
+
function parseMasterKey(base64$1) {
|
|
5532
|
+
const trimmed = base64$1.trim();
|
|
5533
|
+
if (!trimmed) throw new Error("secrets: master key must be a non-empty base64 string");
|
|
5534
|
+
const bytes = base64ToBytes(trimmed);
|
|
5535
|
+
if (bytes.length !== MASTER_KEY_BYTE_LENGTH) throw new Error(`secrets: master key must decode to ${MASTER_KEY_BYTE_LENGTH} bytes (got ${bytes.length})`);
|
|
5536
|
+
return bytes;
|
|
5537
|
+
}
|
|
5538
|
+
/**
|
|
5539
|
+
* Read the master key for `env` from `process.env`.
|
|
5540
|
+
* Never logs or returns the env var name together with the key value.
|
|
5541
|
+
*/
|
|
5542
|
+
function readMasterKeyFromEnv(env) {
|
|
5543
|
+
const varName = masterKeyEnvVarName(env);
|
|
5544
|
+
const raw = process.env[varName];
|
|
5545
|
+
if (!raw) throw new Error(`secrets: master key requires env var ${varName} (set it before running secrets commands)`);
|
|
5546
|
+
return parseMasterKey(raw);
|
|
5547
|
+
}
|
|
5548
|
+
function bytesToBase64(bytes) {
|
|
5549
|
+
return Buffer.from(bytes).toString("base64");
|
|
5550
|
+
}
|
|
5551
|
+
function base64ToBytes(base64$1) {
|
|
5552
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64$1)) throw new Error("secrets: master key is not valid base64");
|
|
5553
|
+
return new Uint8Array(Buffer.from(base64$1, "base64"));
|
|
5554
|
+
}
|
|
5555
|
+
|
|
5556
|
+
//#endregion
|
|
5557
|
+
//#region src/core/naming/NamingEngine.ts
|
|
5558
|
+
function reLiteral(s) {
|
|
5559
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5560
|
+
}
|
|
5561
|
+
var NamingEngine = class {
|
|
5562
|
+
constructor(tenant, conventions) {
|
|
5563
|
+
this.tenant = tenant;
|
|
5564
|
+
this.conventions = conventions;
|
|
5565
|
+
}
|
|
5566
|
+
/** Tenant id for per-resource {@link CloudflareNameFn} overrides. */
|
|
5567
|
+
get tenantId() {
|
|
5568
|
+
return this.tenant.id;
|
|
5569
|
+
}
|
|
5570
|
+
d1SingleName(logicalName, env) {
|
|
5571
|
+
if (this.conventions?.d1Single) return this.conventions.d1Single(logicalName, this.tenant.id, env);
|
|
5572
|
+
return `db_${logicalName}_t_${this.tenant.id}_${env}`;
|
|
5573
|
+
}
|
|
5574
|
+
d1ShardName(logicalName, shardDate, env) {
|
|
5575
|
+
if (this.conventions?.d1Shard) return this.conventions.d1Shard(logicalName, shardDate, this.tenant.id, env);
|
|
5576
|
+
const dateNoDashes = shardDate.replace(/-/g, "");
|
|
5577
|
+
if (logicalName === "default" || logicalName === "") return `db_${dateNoDashes}_t_${this.tenant.id}_${env}`;
|
|
5578
|
+
return `db_${logicalName}_${dateNoDashes}_t_${this.tenant.id}_${env}`;
|
|
5579
|
+
}
|
|
5580
|
+
d1SingleBindingKey(logicalName) {
|
|
5581
|
+
return `DB_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5582
|
+
}
|
|
5583
|
+
d1ShardBindingKey(logicalName, shardDate) {
|
|
5584
|
+
const dateNoDashes = shardDate.replace(/-/g, "");
|
|
5585
|
+
return `DB_${logicalName.toUpperCase().replace(/-/g, "_")}_${dateNoDashes}_T_${this.tenant.id.toUpperCase()}`;
|
|
5586
|
+
}
|
|
5587
|
+
r2BucketName(logicalName, env) {
|
|
5588
|
+
if (this.conventions?.r2Bucket) {
|
|
5589
|
+
const dateNoDashes = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "");
|
|
5590
|
+
return this.conventions.r2Bucket(logicalName, dateNoDashes, this.tenant.id, env);
|
|
5591
|
+
}
|
|
5592
|
+
return `r2-${logicalName}-t-${this.tenant.id}-${env}`;
|
|
5593
|
+
}
|
|
5594
|
+
/**
|
|
5595
|
+
* Wrangler `r2_buckets[].binding`: logical + tenant only (same idea as {@link kvBindingKey}).
|
|
5596
|
+
* Stable across envs; default {@link r2BucketName} is `r2-{logical}-t-{tenant}-{env}`.
|
|
5597
|
+
*/
|
|
5598
|
+
r2BindingKey(logicalName) {
|
|
5599
|
+
return `R2_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5600
|
+
}
|
|
5601
|
+
kvNamespaceName(logicalName, env) {
|
|
5602
|
+
return `kv_${logicalName}_t_${this.tenant.id}_${env}`;
|
|
5603
|
+
}
|
|
5604
|
+
kvBindingKey(logicalName) {
|
|
5605
|
+
return `KV_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5606
|
+
}
|
|
5607
|
+
queueName(logicalName, env) {
|
|
5608
|
+
return `q-${logicalName}-t-${this.tenant.id}-${env}`;
|
|
5609
|
+
}
|
|
5610
|
+
queueBindingKey(logicalName) {
|
|
5611
|
+
return `Q_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5612
|
+
}
|
|
5613
|
+
hyperdriveName(logicalName, env) {
|
|
5614
|
+
return `hd-${logicalName}-t-${this.tenant.id}-${env}`;
|
|
5615
|
+
}
|
|
5616
|
+
hyperdriveBindingKey(logicalName) {
|
|
5617
|
+
return `HD_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5618
|
+
}
|
|
5619
|
+
vectorizeName(logicalName, env) {
|
|
5620
|
+
return `vec-${logicalName}-t-${this.tenant.id}-${env}`;
|
|
5621
|
+
}
|
|
5622
|
+
vectorizeBindingKey(logicalName) {
|
|
5623
|
+
return `VEC_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5624
|
+
}
|
|
5625
|
+
/**
|
|
5626
|
+
* AI Gateway slug (== Cloudflare gateway id). Per the Cloudflare API,
|
|
5627
|
+
* gateway ids are case-sensitive lowercase ascii with `-`/`_`. Format:
|
|
5628
|
+
* `aigw-{logical}-t-{tenantId}-{env}`.
|
|
5629
|
+
*/
|
|
5630
|
+
aiGatewayId(logicalName, env) {
|
|
5631
|
+
return `aigw-${logicalName}-t-${this.tenant.id}-${env}`.toLowerCase();
|
|
5632
|
+
}
|
|
5633
|
+
/**
|
|
5634
|
+
* Stable cross-reference binding key for AI Gateway. AI Gateway has no
|
|
5635
|
+
* Wrangler binding kind today, so this is only consumed by
|
|
5636
|
+
* `${tamer:ai_gateway:<logical>.binding}` interpolations.
|
|
5637
|
+
*/
|
|
5638
|
+
aiGatewayBindingKey(logicalName) {
|
|
5639
|
+
return `AI_GW_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5640
|
+
}
|
|
5641
|
+
/**
|
|
5642
|
+
* Cloudflare-side pipeline name. Pipelines V1 names must be lowercase
|
|
5643
|
+
* alphanumerics + hyphens (no underscores), so we mirror the R2 scheme.
|
|
5644
|
+
* Pattern: `pipe-{logical}-t-{tenantId}-{env}`.
|
|
5645
|
+
*/
|
|
5646
|
+
pipelineName(logicalName, env) {
|
|
5647
|
+
return `pipe-${logicalName}-t-${this.tenant.id}-${env}`.toLowerCase();
|
|
5648
|
+
}
|
|
5649
|
+
/**
|
|
5650
|
+
* Wrangler binding key emitted in `pipelines[]`. Uppercased logical with
|
|
5651
|
+
* the tenant id appended so two tenants can share a worker namespace
|
|
5652
|
+
* without colliding bindings.
|
|
5653
|
+
*/
|
|
5654
|
+
pipelineBindingKey(logicalName) {
|
|
5655
|
+
return `PIPE_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5656
|
+
}
|
|
5657
|
+
pipelineMatchPattern(logicalName, env) {
|
|
5658
|
+
const exact = this.pipelineName(logicalName, env);
|
|
5659
|
+
return (name) => name === exact;
|
|
5660
|
+
}
|
|
5661
|
+
/**
|
|
5662
|
+
* Cloudflare-side workflow name. Workflow names accept lowercase
|
|
5663
|
+
* alphanumerics + hyphens; we mirror the pipelines/AI-Gateway hyphen
|
|
5664
|
+
* scheme. Pattern: `wf-{logical}-t-{tenantId}-{env}`.
|
|
5665
|
+
*/
|
|
5666
|
+
workflowName(logicalName, env) {
|
|
5667
|
+
if (this.conventions?.workflow) return this.conventions.workflow(logicalName, this.tenant.id, env);
|
|
5668
|
+
return `wf-${logicalName}-t-${this.tenant.id}-${env}`.toLowerCase();
|
|
5669
|
+
}
|
|
5670
|
+
/**
|
|
5671
|
+
* Wrangler binding key emitted in `workflows[]`. Uppercased logical with
|
|
5672
|
+
* the tenant id appended so two tenants sharing a script can't collide.
|
|
5673
|
+
*/
|
|
5674
|
+
workflowBindingKey(logicalName) {
|
|
5675
|
+
return `WF_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5676
|
+
}
|
|
5677
|
+
workflowMatchPattern(logicalName, env) {
|
|
5678
|
+
const exact = this.workflowName(logicalName, env);
|
|
5679
|
+
return (name) => name === exact;
|
|
5680
|
+
}
|
|
5681
|
+
/**
|
|
5682
|
+
* Cloudflare Secrets Store name. Account-scoped — the API allows free-form
|
|
5683
|
+
* names (the dashboard examples use both `service_x_keys` and dashed
|
|
5684
|
+
* variants), so we mirror our hyphen convention for consistency with
|
|
5685
|
+
* pipelines / workflows / AI Gateway. Pattern:
|
|
5686
|
+
* `sec-{logical}-t-{tenantId}-{env}`.
|
|
5687
|
+
*/
|
|
5688
|
+
secretsStoreName(logicalName, env) {
|
|
5689
|
+
return `sec-${logicalName}-t-${this.tenant.id}-${env}`.toLowerCase();
|
|
5690
|
+
}
|
|
5691
|
+
/**
|
|
5692
|
+
* Stable cross-reference key for the store. Secrets Store has no Wrangler
|
|
5693
|
+
* binding kind directly — `secrets_store_secrets[]` references the
|
|
5694
|
+
* resolved `store_id`, not the store name — so this only powers
|
|
5695
|
+
* `${tamer:secret_store:<n>.binding}` interpolations.
|
|
5696
|
+
*/
|
|
5697
|
+
secretsStoreBindingKey(logicalName) {
|
|
5698
|
+
return `SEC_${logicalName.toUpperCase().replace(/-/g, "_")}_T_${this.tenant.id.toUpperCase()}`;
|
|
5699
|
+
}
|
|
5700
|
+
secretsStoreMatchPattern(logicalName, env) {
|
|
5701
|
+
const exact = this.secretsStoreName(logicalName, env);
|
|
5702
|
+
return (name) => name === exact;
|
|
5703
|
+
}
|
|
5704
|
+
workerName(workerKey, env) {
|
|
5705
|
+
if (this.conventions?.workerName) return this.conventions.workerName(this.tenant.slug, workerKey, env, this.tenant.id);
|
|
5706
|
+
if (env === "local") return `${this.tenant.slug}-${workerKey}-${this.tenant.id}`;
|
|
5707
|
+
return `${this.tenant.slug}-${workerKey}-${env}-${this.tenant.id}`;
|
|
5708
|
+
}
|
|
5709
|
+
/** Whether stack {@link NamingConventions.d1Shard} is configured. */
|
|
5710
|
+
hasD1ShardConvention() {
|
|
5711
|
+
return Boolean(this.conventions?.d1Shard);
|
|
5712
|
+
}
|
|
5713
|
+
d1MatchPattern(logicalName, env) {
|
|
5714
|
+
if (this.conventions?.d1Shard) return (name) => {
|
|
5715
|
+
const shardDate = this.extractD1ShardDate(name);
|
|
5716
|
+
if (!shardDate) return false;
|
|
5717
|
+
return this.d1ShardName(logicalName, shardDate, env) === name;
|
|
5718
|
+
};
|
|
5719
|
+
const suffix = `_t_${this.tenant.id}_${env}`;
|
|
5720
|
+
if (logicalName === "default" || logicalName === "") return (name) => /^db_\d{8}_t_/.test(name) && name.endsWith(suffix);
|
|
5721
|
+
const prefix = `db_${logicalName}_`;
|
|
5722
|
+
return (name) => name.startsWith(prefix) && name.endsWith(suffix);
|
|
5723
|
+
}
|
|
5724
|
+
/**
|
|
5725
|
+
* Default: exact {@link r2BucketName}, or legacy dated buckets
|
|
5726
|
+
* `r2-{logical}-{YYYYMMDD}-t-{tenant}-{env}` from older Tamer versions.
|
|
5727
|
+
* Custom {@link NamingConventions.r2Bucket}: exact name only (uses today's date
|
|
5728
|
+
* when calling the hook, same as apply).
|
|
5729
|
+
*/
|
|
5730
|
+
r2MatchPattern(logicalName, env) {
|
|
5731
|
+
if (this.conventions?.r2Bucket) {
|
|
5732
|
+
const expected = this.r2BucketName(logicalName, env);
|
|
5733
|
+
return (name) => name === expected;
|
|
5734
|
+
}
|
|
5735
|
+
const exactNew = `r2-${logicalName}-t-${this.tenant.id}-${env}`;
|
|
5736
|
+
const legacyDated = /* @__PURE__ */ new RegExp(`^r2-${reLiteral(logicalName)}-\\d{8}-t-${reLiteral(this.tenant.id)}-${reLiteral(env)}$`);
|
|
5737
|
+
return (name) => name === exactNew || legacyDated.test(name);
|
|
5738
|
+
}
|
|
5739
|
+
extractD1ShardDate(name) {
|
|
5740
|
+
const compact = name.match(/_(\d{8})_t_/);
|
|
5741
|
+
if (compact) {
|
|
5742
|
+
const d = compact[1];
|
|
5743
|
+
return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`;
|
|
5744
|
+
}
|
|
5745
|
+
const underscored = name.match(/_(\d{4})_(\d{2})_(\d{2})_t_/);
|
|
5746
|
+
if (underscored) return `${underscored[1]}-${underscored[2]}-${underscored[3]}`;
|
|
5747
|
+
return null;
|
|
5748
|
+
}
|
|
5749
|
+
extractR2Date(name) {
|
|
5750
|
+
const match = name.match(/r2-\w+-(\d{8})-t-/);
|
|
5751
|
+
if (match) {
|
|
5752
|
+
const d = match[1];
|
|
5753
|
+
return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`;
|
|
5754
|
+
}
|
|
5755
|
+
return null;
|
|
5756
|
+
}
|
|
5757
|
+
};
|
|
5758
|
+
|
|
5759
|
+
//#endregion
|
|
5760
|
+
//#region src/core/config/namingFromConfig.ts
|
|
5761
|
+
function namingFromConfig(config$1) {
|
|
5762
|
+
const conventions = "naming" in config$1 && config$1.naming ? config$1.naming : void 0;
|
|
5763
|
+
return new NamingEngine(config$1.tenant, conventions);
|
|
5764
|
+
}
|
|
5765
|
+
|
|
5766
|
+
//#endregion
|
|
5767
|
+
//#region src/features/dispatch-namespace/dispatch-namespace.resolve.ts
|
|
5768
|
+
const ephemeralPredicateCache = /* @__PURE__ */ new WeakMap();
|
|
5769
|
+
function ephemeralPredicateFor(tenant) {
|
|
5770
|
+
if (ephemeralPredicateCache.has(tenant)) return ephemeralPredicateCache.get(tenant) ?? null;
|
|
5771
|
+
const pat = tenant.ephemeralEnvPattern;
|
|
5772
|
+
const compiled = pat ? new RegExp(pat) : null;
|
|
5773
|
+
ephemeralPredicateCache.set(tenant, compiled);
|
|
5774
|
+
return compiled;
|
|
5775
|
+
}
|
|
5776
|
+
/**
|
|
5777
|
+
* `true` when `env` matches `tenant.ephemeralEnvPattern` (e.g.
|
|
5778
|
+
* `"^pr-"` for PR previews, `"^(pr|feature)-"` for branch previews).
|
|
5779
|
+
*
|
|
5780
|
+
* Ephemeral envs share **one** dispatch namespace
|
|
5781
|
+
* (`{ns}-ephemeral`) so we don't churn dispatch-namespace creates per
|
|
5782
|
+
* preview, and dispatch-script names carry the env suffix
|
|
5783
|
+
* (`{product}-{workspace}-{env}`) so multiple previews can coexist
|
|
5784
|
+
* inside that shared namespace. When the config doesn't pin a
|
|
5785
|
+
* pattern, no env is ephemeral — every env gets its own dispatch
|
|
5786
|
+
* namespace `{ns}-{env}`.
|
|
5787
|
+
*/
|
|
5788
|
+
function isEphemeralEnv(env, tenant) {
|
|
5789
|
+
const re = ephemeralPredicateFor(tenant);
|
|
5790
|
+
if (!re) return false;
|
|
5791
|
+
return re.test(env);
|
|
5792
|
+
}
|
|
5793
|
+
/** Resolved Cloudflare dispatch namespace name for the given env. */
|
|
5794
|
+
function effectiveDispatchNamespaceName(config$1, env, tenant) {
|
|
5795
|
+
if (config$1.envSuffix) {
|
|
5796
|
+
if (env === "local") return config$1.namespace;
|
|
5797
|
+
if (isEphemeralEnv(env, tenant)) return `${config$1.namespace}-ephemeral`;
|
|
5798
|
+
return `${config$1.namespace}-${env}`;
|
|
5799
|
+
}
|
|
5800
|
+
return config$1.namespace;
|
|
5801
|
+
}
|
|
5802
|
+
|
|
5803
|
+
//#endregion
|
|
5804
|
+
//#region src/core/routes/routes.resolve.ts
|
|
5805
|
+
const DEFAULT_PROD_ENVS = ["prod", "production"];
|
|
5806
|
+
const DEFAULT_SKIP_ENVS = ["local"];
|
|
5807
|
+
/**
|
|
5808
|
+
* Per `docs/handoff.md` §6:
|
|
5809
|
+
* - prod / production → bare apex (`todo.com`)
|
|
5810
|
+
* - any other env (including ephemeral `pr-*`) → `{env}.{apex}`
|
|
5811
|
+
* - `local` (or anything in `skipEnvs`) → no route
|
|
5812
|
+
*
|
|
5813
|
+
* The resource-name `-{env}` suffix on workers/D1/R2/KV is decoupled from
|
|
5814
|
+
* URLs; this function only computes the URL.
|
|
5815
|
+
*/
|
|
5816
|
+
function effectiveHostForEnv(route, env) {
|
|
5817
|
+
if ((route.skipEnvs ?? DEFAULT_SKIP_ENVS).includes(env)) return void 0;
|
|
5818
|
+
if ((route.prodEnvs ?? DEFAULT_PROD_ENVS).includes(env)) return route.host;
|
|
5819
|
+
return `${env}.${route.host}`;
|
|
5820
|
+
}
|
|
5821
|
+
/**
|
|
5822
|
+
* Expand one Tamer route into a wrangler `Route` (or `undefined` if the env
|
|
5823
|
+
* should not receive the route at all).
|
|
5824
|
+
*/
|
|
5825
|
+
function expandRouteForEnv(route, env) {
|
|
5826
|
+
const host = effectiveHostForEnv(route, env);
|
|
5827
|
+
if (!host) return void 0;
|
|
5828
|
+
const zone = route.zone ?? route.host;
|
|
5829
|
+
if (route.customDomain) return {
|
|
5830
|
+
pattern: host,
|
|
5831
|
+
custom_domain: true
|
|
5832
|
+
};
|
|
5833
|
+
return {
|
|
5834
|
+
pattern: `${host}${route.path ?? "/*"}`,
|
|
5835
|
+
zone_name: zone
|
|
5836
|
+
};
|
|
5837
|
+
}
|
|
5838
|
+
/**
|
|
5839
|
+
* Expand `tamerRoutes` for the given env, dropping any that resolve to
|
|
5840
|
+
* `undefined` (`local`, `skipEnvs`).
|
|
5841
|
+
*
|
|
5842
|
+
* Note: ephemeral envs (matching `tenant.ephemeralEnvPattern`) follow the
|
|
5843
|
+
* same `{env}.{apex}` prefix rule as any non-prod env — e.g. an env named
|
|
5844
|
+
* `pr-1234` resolves to `pr-1234.todo.com`. Callers that want a different
|
|
5845
|
+
* URL scheme for ephemeral envs should special-case before calling.
|
|
5846
|
+
*/
|
|
5847
|
+
function effectiveRoutesForEnv(tamerRoutes, env) {
|
|
5848
|
+
if (!tamerRoutes || tamerRoutes.length === 0) return [];
|
|
5849
|
+
const out = [];
|
|
5850
|
+
for (const r of tamerRoutes) {
|
|
5851
|
+
const expanded = expandRouteForEnv(r, env);
|
|
5852
|
+
if (expanded) out.push(expanded);
|
|
5853
|
+
}
|
|
5854
|
+
return out;
|
|
5855
|
+
}
|
|
5856
|
+
/**
|
|
5857
|
+
* Zone-name routes are attached via the Cloudflare Workers Routes HTTP API
|
|
5858
|
+
* (`/zones/{id}/workers/routes`), not via `wrangler.json`, so deploys stay
|
|
5859
|
+
* consistent with `tamer drift` / destroy.
|
|
5860
|
+
*/
|
|
5861
|
+
function isApiManagedZoneRoute(r) {
|
|
5862
|
+
return typeof r === "object" && r !== null && "zone_name" in r && typeof r.zone_name === "string" && "pattern" in r && typeof r.pattern === "string" && !("custom_domain" in r && r.custom_domain === true);
|
|
5863
|
+
}
|
|
5864
|
+
/** Custom-domain `tamerRoutes` stay in wrangler until a dedicated API path exists. */
|
|
5865
|
+
function isWranglerOnlyTamerRoute(r) {
|
|
5866
|
+
return typeof r === "object" && r !== null && "custom_domain" in r && r.custom_domain === true;
|
|
5867
|
+
}
|
|
5868
|
+
|
|
5869
|
+
//#endregion
|
|
5870
|
+
//#region src/core/wrangler/wranglerOutFile.ts
|
|
5871
|
+
/** Reject path segments in generated Wrangler config filename. */
|
|
5872
|
+
function assertSafeWranglerOutFile(name) {
|
|
5873
|
+
const trimmed = name.trim();
|
|
5874
|
+
if (!trimmed) throw new Error("wranglerOutFile cannot be empty");
|
|
5875
|
+
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith("..")) throw new Error(`Invalid wranglerOutFile "${name}": use a basename only (e.g. wrangler.json)`);
|
|
5876
|
+
return trimmed;
|
|
5877
|
+
}
|
|
5878
|
+
/** Extra CLI args so Wrangler uses a non-default config file. */
|
|
5879
|
+
function wranglerConfigCliArgs(outFile) {
|
|
5880
|
+
if (outFile === "wrangler.json") return [];
|
|
5881
|
+
return ["--config", outFile];
|
|
5882
|
+
}
|
|
5883
|
+
|
|
5884
|
+
//#endregion
|
|
5885
|
+
//#region src/core/references/references.ts
|
|
5886
|
+
var TamerReferenceError = class extends Error {
|
|
5887
|
+
constructor(message, fieldPath) {
|
|
5888
|
+
super(`${message} (at ${fieldPath})`);
|
|
5889
|
+
this.fieldPath = fieldPath;
|
|
5890
|
+
this.name = "TamerReferenceError";
|
|
5891
|
+
}
|
|
5892
|
+
};
|
|
5893
|
+
const REF_RE = /\$\{tamer:([a-z0-9_]+):([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\}/g;
|
|
5894
|
+
/**
|
|
5895
|
+
* Scan a string for any `${tamer:...}` references. Returns true when at
|
|
5896
|
+
* least one reference is present (used for cheap pre-checks).
|
|
5897
|
+
*/
|
|
5898
|
+
function stringHasReference(s) {
|
|
5899
|
+
REF_RE.lastIndex = 0;
|
|
5900
|
+
return REF_RE.test(s);
|
|
5901
|
+
}
|
|
5902
|
+
/**
|
|
5903
|
+
* Resolve every `${tamer:...}` reference in `value`. Replacement preserves
|
|
5904
|
+
* surrounding text for partial-string interpolation. `fieldPath` is included
|
|
5905
|
+
* in any thrown {@link TamerReferenceError} for actionable diagnostics.
|
|
5906
|
+
*/
|
|
5907
|
+
function resolveReferencesInString(value, ctx, fieldPath) {
|
|
5908
|
+
if (!stringHasReference(value)) return value;
|
|
5909
|
+
return value.replace(REF_RE, (match, kind, logicalName, field) => {
|
|
5910
|
+
try {
|
|
5911
|
+
return lookupReference(kind, logicalName, field, ctx, fieldPath);
|
|
5912
|
+
} catch (err) {
|
|
5913
|
+
if (ctx.tolerant && err instanceof TamerReferenceError) return match;
|
|
5914
|
+
throw err;
|
|
5915
|
+
}
|
|
5916
|
+
});
|
|
5917
|
+
}
|
|
5918
|
+
/**
|
|
5919
|
+
* Walk a `vars` record (or any flat string→string map) replacing references
|
|
5920
|
+
* in every value. Returns a new object; the input is not mutated.
|
|
5921
|
+
*/
|
|
5922
|
+
function resolveReferencesInVars(vars, ctx, fieldPathPrefix) {
|
|
5923
|
+
if (!vars) return vars;
|
|
5924
|
+
const out = {};
|
|
5925
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
5926
|
+
if (typeof value !== "string") {
|
|
5927
|
+
out[key] = value;
|
|
5928
|
+
continue;
|
|
5929
|
+
}
|
|
5930
|
+
out[key] = resolveReferencesInString(value, ctx, `${fieldPathPrefix}.${key}`);
|
|
5931
|
+
}
|
|
5932
|
+
return out;
|
|
5933
|
+
}
|
|
5934
|
+
function lookupReference(kind, logicalName, field, ctx, fieldPath) {
|
|
5935
|
+
switch (kind) {
|
|
5936
|
+
case "d1": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "d1_database" && entry.logicalName === logicalName);
|
|
5937
|
+
case "r2": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "r2_bucket" && entry.logicalName === logicalName);
|
|
5938
|
+
case "kv": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "kv_namespace" && entry.logicalName === logicalName);
|
|
5939
|
+
case "queue": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "queue" && entry.logicalName === logicalName);
|
|
5940
|
+
case "hyperdrive": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "hyperdrive" && entry.logicalName === logicalName);
|
|
5941
|
+
case "vectorize": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "vectorize" && entry.logicalName === logicalName);
|
|
5942
|
+
case "ai_gateway": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "ai_gateway" && entry.logicalName === logicalName);
|
|
5943
|
+
case "pipeline": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "pipeline" && entry.logicalName === logicalName);
|
|
5944
|
+
case "workflow": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "workflow" && entry.logicalName === logicalName);
|
|
5945
|
+
case "secret_store": return lookupResource(ctx, kind, logicalName, field, fieldPath, (entry) => entry.type === "secrets_store" && entry.logicalName === logicalName);
|
|
5946
|
+
case "dispatch_namespace": return lookupDispatchNamespace(ctx, logicalName, field, fieldPath);
|
|
5947
|
+
case "worker": return lookupWorker(ctx, logicalName, field, fieldPath);
|
|
5948
|
+
case "import": return lookupImport(ctx, logicalName, field, fieldPath);
|
|
5949
|
+
case "logpush_pipelines": return lookupLogpushPipelines(ctx, logicalName, field, fieldPath);
|
|
5950
|
+
case "config": return lookupConfigField(ctx, logicalName, field, fieldPath);
|
|
5951
|
+
default: throw new TamerReferenceError(`Unknown reference kind "${kind}" — expected one of d1 | r2 | kv | queue | hyperdrive | vectorize | ai_gateway | pipeline | workflow | secret_store | dispatch_namespace | worker | import | logpush_pipelines | config`, fieldPath);
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
5954
|
+
function lookupConfigField(ctx, _logicalName, field, fieldPath) {
|
|
5955
|
+
if (field === "account_id") {
|
|
5956
|
+
const id = (ctx.accountId ?? ctx.config.account_id ?? "").trim();
|
|
5957
|
+
if (!id) throw new TamerReferenceError(`Reference \${tamer:config:stack.account_id} needs the stack Cloudflare account id — set top-level account_id in tamer/project.config.ts or pass CLOUDFLARE_ACCOUNT_ID when running tamer.`, fieldPath);
|
|
5958
|
+
return id;
|
|
5959
|
+
}
|
|
5960
|
+
throw new TamerReferenceError(`Unknown field "${field}" on config reference — expected account_id`, fieldPath);
|
|
5961
|
+
}
|
|
5962
|
+
function getLogpushPipelinesEntry(ctx, logicalName, fieldPath) {
|
|
5963
|
+
const all = ctx.state.getAll();
|
|
5964
|
+
const entry = Object.values(all).find((e) => e.type === "logpush_pipelines" && e.logicalName === logicalName);
|
|
5965
|
+
if (!entry) throw new TamerReferenceError(`Reference \${tamer:logpush_pipelines:${logicalName}.…} cannot be resolved — no pipelines graph in state for logpush job "${logicalName}". Run a full \`tamer apply\` (including the Logpush / pipelinesAuto step) for env "${ctx.env}" first.`, fieldPath);
|
|
5966
|
+
return entry;
|
|
5967
|
+
}
|
|
5968
|
+
/**
|
|
5969
|
+
* Exposes fields from the `logpush_pipelines:*` D1 state row (after
|
|
5970
|
+
* `ensurePipelinesLogpushProvision` has run) for `outputs` and `vars`, e.g.
|
|
5971
|
+
* `${tamer:logpush_pipelines:workers-trace.r2_data_catalog_table_name}`.
|
|
5972
|
+
*/
|
|
5973
|
+
function lookupLogpushPipelines(ctx, logicalName, field, fieldPath) {
|
|
5974
|
+
const entry = getLogpushPipelinesEntry(ctx, logicalName, fieldPath);
|
|
5975
|
+
switch (field) {
|
|
5976
|
+
case "r2_data_catalog_table_name":
|
|
5977
|
+
case "iceberg_table": {
|
|
5978
|
+
const v = entry.r2DataCatalogTableName?.trim();
|
|
5979
|
+
if (!v) throw new TamerReferenceError(`logpush_pipelines state for "${logicalName}" has no r2DataCatalogTableName (sink not yet created, or pre-upgrade state). Re-run a full \`tamer apply\` for env "${ctx.env}" after pipelinesAuto provisions the sink, or set table manually in consuming stack.`, fieldPath);
|
|
5980
|
+
return v;
|
|
5981
|
+
}
|
|
5982
|
+
case "r2_data_catalog_table_name_pipelines":
|
|
5983
|
+
case "iceberg_table_pipelines": {
|
|
5984
|
+
const v = entry.r2DataCatalogTableNamePipelines?.trim();
|
|
5985
|
+
if (v) return v;
|
|
5986
|
+
const fallback = entry.r2DataCatalogTableName?.trim();
|
|
5987
|
+
if (!fallback) throw new TamerReferenceError(`logpush_pipelines state for "${logicalName}" has no table name (sink not yet created). Re-run \`tamer apply\` for env "${ctx.env}".`, fieldPath);
|
|
5988
|
+
return fallback;
|
|
5989
|
+
}
|
|
5990
|
+
case "r2_data_catalog_namespace":
|
|
5991
|
+
case "iceberg_namespace": return (entry.r2DataCatalogNamespace ?? "default").trim() || "default";
|
|
5992
|
+
case "name": return entry.pipelineName;
|
|
5993
|
+
case "id": return entry.pipelineId;
|
|
5994
|
+
default: throw new TamerReferenceError(`Unknown field "${field}" on logpush_pipelines reference — expected r2_data_catalog_table_name | r2_data_catalog_table_name_pipelines | r2_data_catalog_namespace | name | id | iceberg_table | iceberg_table_pipelines | iceberg_namespace`, fieldPath);
|
|
5995
|
+
}
|
|
5996
|
+
}
|
|
5997
|
+
/**
|
|
5998
|
+
* Resolve a `${tamer:import:<stack>.<output>}` against pre-fetched sibling
|
|
5999
|
+
* stack outputs. The pre-fetch (`fetchStackImports`) loads every imported
|
|
6000
|
+
* stack's `cfi_state:{stack}` row before resolution begins; this lookup is
|
|
6001
|
+
* pure map access. Throws if the stack isn't in `ctx.imports` (config
|
|
6002
|
+
* never declared it / pre-fetch wasn't wired in for this command) or if
|
|
6003
|
+
* the named output hasn't been published yet (sibling stack hasn't run
|
|
6004
|
+
* `apply` since the output was declared, or has been destroyed).
|
|
6005
|
+
*/
|
|
6006
|
+
function lookupImport(ctx, stackName, outputName, fieldPath) {
|
|
6007
|
+
const imports = ctx.imports;
|
|
6008
|
+
if (!imports || !(stackName in imports)) throw new TamerReferenceError(`Reference \${tamer:import:${stackName}.${outputName}} cannot be resolved — no imported stack "${stackName}" available. Ensure stack "${stackName}" exists in env "${ctx.env}" (run 'tamer apply' there first) and that the current command pre-fetches sibling stacks.`, fieldPath);
|
|
6009
|
+
const stackOutputs = imports[stackName];
|
|
6010
|
+
if (!(outputName in stackOutputs)) {
|
|
6011
|
+
const available = Object.keys(stackOutputs).sort();
|
|
6012
|
+
throw new TamerReferenceError(`Reference \${tamer:import:${stackName}.${outputName}} cannot be resolved — output "${outputName}" not found on imported stack "${stackName}".${available.length > 0 ? ` Available outputs on stack "${stackName}": ${available.join(", ")}.` : ` Stack "${stackName}" has no published outputs — run 'tamer apply' there with an \`outputs:\` block.`}`, fieldPath);
|
|
6013
|
+
}
|
|
6014
|
+
return stackOutputs[outputName];
|
|
6015
|
+
}
|
|
6016
|
+
function lookupResource(ctx, kind, logicalName, field, fieldPath, predicate) {
|
|
6017
|
+
const all = ctx.state.getAll();
|
|
6018
|
+
const entry = Object.values(all).find(predicate);
|
|
6019
|
+
if (!entry) throw new TamerReferenceError(`Reference \${tamer:${kind}:${logicalName}.${field}} cannot be resolved — no ${kind} resource named "${logicalName}" in state. Run 'tamer apply --env ${ctx.env}' first.`, fieldPath);
|
|
6020
|
+
switch (field) {
|
|
6021
|
+
case "name": return resourceName(entry);
|
|
6022
|
+
case "id": return resourceId(entry, kind, logicalName, fieldPath);
|
|
6023
|
+
case "binding": return resourceBinding(entry);
|
|
6024
|
+
default: throw new TamerReferenceError(`Unknown field "${field}" on ${kind} reference — expected name | id | binding`, fieldPath);
|
|
6025
|
+
}
|
|
6026
|
+
}
|
|
6027
|
+
function resourceName(entry) {
|
|
6028
|
+
switch (entry.type) {
|
|
6029
|
+
case "d1_database":
|
|
6030
|
+
case "kv_namespace":
|
|
6031
|
+
case "queue":
|
|
6032
|
+
case "hyperdrive":
|
|
6033
|
+
case "vectorize":
|
|
6034
|
+
case "ai_gateway":
|
|
6035
|
+
case "pipeline":
|
|
6036
|
+
case "workflow":
|
|
6037
|
+
case "secrets_store":
|
|
6038
|
+
case "dispatch_namespace":
|
|
6039
|
+
case "r2_bucket": return entry.derivedName;
|
|
6040
|
+
case "dns_record": return entry.name;
|
|
6041
|
+
case "logpush_job": return entry.derivedName;
|
|
6042
|
+
case "logpush_pipelines": return entry.pipelineName;
|
|
6043
|
+
case "worker_route": return entry.pattern;
|
|
6044
|
+
case "secret": throw new Error("internal: secret state entries have no .name reference — secrets are not cross-referenced via ${tamer:…}");
|
|
6045
|
+
}
|
|
6046
|
+
}
|
|
6047
|
+
function resourceId(entry, kind, logicalName, fieldPath) {
|
|
6048
|
+
switch (entry.type) {
|
|
6049
|
+
case "d1_database":
|
|
6050
|
+
case "kv_namespace":
|
|
6051
|
+
case "queue":
|
|
6052
|
+
case "hyperdrive":
|
|
6053
|
+
case "vectorize":
|
|
6054
|
+
case "ai_gateway":
|
|
6055
|
+
case "pipeline":
|
|
6056
|
+
case "workflow":
|
|
6057
|
+
case "secrets_store": return entry.cfId;
|
|
6058
|
+
case "r2_bucket": throw new TamerReferenceError(`R2 bucket "${logicalName}" has no .id (R2 buckets are addressed by name); use \${tamer:${kind}:${logicalName}.name}`, fieldPath);
|
|
6059
|
+
case "dispatch_namespace": return entry.derivedName;
|
|
6060
|
+
case "dns_record": return entry.recordId;
|
|
6061
|
+
case "logpush_job": return String(entry.cfJobId);
|
|
6062
|
+
case "logpush_pipelines": return entry.pipelineId;
|
|
6063
|
+
case "worker_route": return entry.routeId;
|
|
6064
|
+
case "secret": throw new Error("internal: secret state entries have no .id — secrets are not cross-referenced via ${tamer:…}");
|
|
6065
|
+
}
|
|
6066
|
+
}
|
|
6067
|
+
function resourceBinding(entry) {
|
|
6068
|
+
switch (entry.type) {
|
|
6069
|
+
case "d1_database":
|
|
6070
|
+
case "r2_bucket":
|
|
6071
|
+
case "kv_namespace":
|
|
6072
|
+
case "queue":
|
|
6073
|
+
case "hyperdrive":
|
|
6074
|
+
case "vectorize":
|
|
6075
|
+
case "ai_gateway":
|
|
6076
|
+
case "pipeline":
|
|
6077
|
+
case "workflow":
|
|
6078
|
+
case "secrets_store": return entry.bindingKey;
|
|
6079
|
+
case "dispatch_namespace": return entry.derivedName;
|
|
6080
|
+
case "dns_record": throw new Error("internal: dns_record has no .binding — use .name or .id in config references");
|
|
6081
|
+
case "logpush_job": throw new Error("internal: logpush_job has no .binding — use .name or .id in config references");
|
|
6082
|
+
case "logpush_pipelines": throw new Error("internal: logpush_pipelines has no .binding — use .name or .id in config references");
|
|
6083
|
+
case "worker_route": return entry.routeId;
|
|
6084
|
+
case "secret": throw new Error("internal: secret state entries have no .binding — secrets are not cross-referenced via ${tamer:…}");
|
|
6085
|
+
}
|
|
6086
|
+
}
|
|
6087
|
+
function lookupDispatchNamespace(ctx, logicalName, field, fieldPath) {
|
|
6088
|
+
if (!getDispatchNamespaces(ctx.config).find((d) => d.logicalName === logicalName)) throw new TamerReferenceError(`Reference \${tamer:dispatch_namespace:${logicalName}.${field}} cannot be resolved — no dispatchNamespaces entry named "${logicalName}" in the Tamer project config`, fieldPath);
|
|
6089
|
+
if (field !== "name" && field !== "id") throw new TamerReferenceError(`Unknown field "${field}" on dispatch_namespace reference — expected name | id`, fieldPath);
|
|
6090
|
+
const all = ctx.state.getAll();
|
|
6091
|
+
const stateEntry = Object.values(all).find((e) => e.type === "dispatch_namespace" && e.logicalName === logicalName);
|
|
6092
|
+
if (!stateEntry || stateEntry.type !== "dispatch_namespace") throw new TamerReferenceError(`Reference \${tamer:dispatch_namespace:${logicalName}.${field}} unresolved — no state entry. Run 'tamer apply --env ${ctx.env}' first.`, fieldPath);
|
|
6093
|
+
return stateEntry.derivedName;
|
|
6094
|
+
}
|
|
6095
|
+
function lookupWorker(ctx, workerKey, field, fieldPath) {
|
|
6096
|
+
let target;
|
|
6097
|
+
if (ctx.config.workers && ctx.config.workers[workerKey]) target = ctx.config.workers[workerKey];
|
|
6098
|
+
else if (ctx.config.worker && workerKey === "default") target = ctx.config.worker;
|
|
6099
|
+
if (!target) throw new TamerReferenceError(`Reference \${tamer:worker:${workerKey}.${field}} cannot be resolved — no worker key "${workerKey}" in the Tamer project config`, fieldPath);
|
|
6100
|
+
if (field !== "name") throw new TamerReferenceError(`Unknown field "${field}" on worker reference — only "name" is supported (the env-suffixed deployed script name)`, fieldPath);
|
|
6101
|
+
return resolveDeployedWorkerName(ctx.config, workerKey, target, ctx.env, ctx.naming);
|
|
6102
|
+
}
|
|
6103
|
+
|
|
6104
|
+
//#endregion
|
|
6105
|
+
//#region src/core/config/resolver.ts
|
|
6106
|
+
/** Wrangler script name after env suffix rules (matches `tamer deploy`). */
|
|
6107
|
+
function resolveDeployedWorkerName(_config, workerKey, workerConfig, env, naming) {
|
|
6108
|
+
const sn = workerConfig.scriptName?.trim();
|
|
6109
|
+
if (sn) {
|
|
6110
|
+
if (env === "local") return sn;
|
|
6111
|
+
return `${sn}-${env}`;
|
|
6112
|
+
}
|
|
6113
|
+
return naming.workerName(workerKey, env);
|
|
6114
|
+
}
|
|
6115
|
+
/**
|
|
6116
|
+
* Map from base service-binding target name (`scriptName` for envs other than
|
|
6117
|
+
* `local`, or the local-deployed name) → env-suffixed deployed name for every
|
|
6118
|
+
* worker in `config`. Used to auto-rewrite intra-stack `services[].service`
|
|
6119
|
+
* fields so fixtures don't have to repeat env overrides for every env.
|
|
6120
|
+
*/
|
|
6121
|
+
function buildIntraStackScriptNameMap(config$1, env, naming) {
|
|
6122
|
+
const map = /* @__PURE__ */ new Map();
|
|
6123
|
+
if (config$1.workers) for (const [key, wc] of Object.entries(config$1.workers)) {
|
|
6124
|
+
const baseName = wc.scriptName?.trim() ?? naming.workerName(key, "local");
|
|
6125
|
+
const deployed = resolveDeployedWorkerName(config$1, key, wc, env, naming);
|
|
6126
|
+
map.set(baseName, deployed);
|
|
6127
|
+
}
|
|
6128
|
+
if (config$1.worker) {
|
|
6129
|
+
const w = config$1.worker;
|
|
6130
|
+
const baseName = w.scriptName?.trim() ?? naming.workerName("default", "local");
|
|
6131
|
+
const deployed = resolveDeployedWorkerName(config$1, "default", w, env, naming);
|
|
6132
|
+
map.set(baseName, deployed);
|
|
6133
|
+
}
|
|
6134
|
+
return map;
|
|
6135
|
+
}
|
|
6136
|
+
/** Returns a worker config copy whose `services[].service` is rewritten to env-suffixed deployed names. */
|
|
6137
|
+
function rewriteIntraStackServiceTargets(workerConfig, baseToDeployed) {
|
|
6138
|
+
const services = workerConfig.services;
|
|
6139
|
+
if (!services || services.length === 0) return workerConfig;
|
|
6140
|
+
let rewroteAny = false;
|
|
6141
|
+
const rewritten = services.map((s) => {
|
|
6142
|
+
const target = baseToDeployed.get(s.service);
|
|
6143
|
+
if (!target || target === s.service) return s;
|
|
6144
|
+
rewroteAny = true;
|
|
6145
|
+
return {
|
|
6146
|
+
...s,
|
|
6147
|
+
service: target
|
|
6148
|
+
};
|
|
6149
|
+
});
|
|
6150
|
+
if (!rewroteAny) return workerConfig;
|
|
6151
|
+
return {
|
|
6152
|
+
...workerConfig,
|
|
6153
|
+
services: rewritten
|
|
6154
|
+
};
|
|
6155
|
+
}
|
|
6156
|
+
function mergeVars(base = {}, override = {}) {
|
|
6157
|
+
return {
|
|
6158
|
+
...base,
|
|
6159
|
+
...override
|
|
6160
|
+
};
|
|
6161
|
+
}
|
|
6162
|
+
/** Same env merge as `resolveWorkerConfig` (for deploy topo-sort service edges). */
|
|
6163
|
+
function mergedWorkerConfigForEnv(workerConfig, env, tenant) {
|
|
6164
|
+
let merged = { ...workerConfig };
|
|
6165
|
+
if (env === "local" && workerConfig.local) merged = {
|
|
6166
|
+
...merged,
|
|
6167
|
+
...workerConfig.local,
|
|
6168
|
+
vars: mergeVars(workerConfig.vars, workerConfig.local.vars)
|
|
6169
|
+
};
|
|
6170
|
+
else if (workerConfig.env?.[env]) {
|
|
6171
|
+
const envOverride = workerConfig.env[env];
|
|
6172
|
+
merged = {
|
|
6173
|
+
...merged,
|
|
6174
|
+
...envOverride,
|
|
6175
|
+
vars: mergeVars(workerConfig.vars, envOverride.vars)
|
|
6176
|
+
};
|
|
6177
|
+
}
|
|
6178
|
+
const mv = merged.vars;
|
|
6179
|
+
if (mv && Object.prototype.hasOwnProperty.call(mv, "BRANCH_SUFFIX")) merged = {
|
|
6180
|
+
...merged,
|
|
6181
|
+
vars: {
|
|
6182
|
+
...mv,
|
|
6183
|
+
BRANCH_SUFFIX: isEphemeralEnv(env, tenant) ? env : ""
|
|
6184
|
+
}
|
|
6185
|
+
};
|
|
6186
|
+
return merged;
|
|
6187
|
+
}
|
|
6188
|
+
/**
|
|
6189
|
+
* Align `dispatch_namespaces[].namespace` and `vars.WFP_NAMESPACE` with
|
|
6190
|
+
* {@link effectiveDispatchNamespaceName} for envs that have no explicit
|
|
6191
|
+
* `worker.env[env]` block (e.g. `pr-*` shared namespace).
|
|
6192
|
+
*/
|
|
6193
|
+
function applyDispatchNamespaceEnvOverrides(config$1, merged, env) {
|
|
6194
|
+
const dns = getDispatchNamespaces(config$1);
|
|
6195
|
+
if (dns.length === 0) return merged;
|
|
6196
|
+
const resolved = effectiveDispatchNamespaceName(dns[0], env, config$1.tenant);
|
|
6197
|
+
const m = merged;
|
|
6198
|
+
let next = merged;
|
|
6199
|
+
if (m.dispatch_namespaces?.length) next = {
|
|
6200
|
+
...next,
|
|
6201
|
+
dispatch_namespaces: m.dispatch_namespaces.map((d) => ({
|
|
6202
|
+
...d,
|
|
6203
|
+
namespace: resolved
|
|
6204
|
+
}))
|
|
6205
|
+
};
|
|
6206
|
+
if (m.vars && typeof m.vars.WFP_NAMESPACE === "string" && m.vars.WFP_NAMESPACE === dns[0].namespace) {
|
|
6207
|
+
const v = next.vars;
|
|
6208
|
+
next = {
|
|
6209
|
+
...next,
|
|
6210
|
+
vars: {
|
|
6211
|
+
...v,
|
|
6212
|
+
WFP_NAMESPACE: resolved
|
|
6213
|
+
}
|
|
6214
|
+
};
|
|
6215
|
+
}
|
|
6216
|
+
return next;
|
|
6217
|
+
}
|
|
6218
|
+
/**
|
|
6219
|
+
* Walk the merged worker config and replace `${tamer:<kind>:<logical>.<field>}`
|
|
6220
|
+
* references in `vars` and `tamerRoutes[].host` / `.zone` against the current
|
|
6221
|
+
* state snapshot. Also resolves `r2_buckets[].bucket_name`,
|
|
6222
|
+
* **`services[].service`**, **`dispatch_namespaces[].namespace`**, and
|
|
6223
|
+
* `resources.d1[].databaseName` when `ownership` is `external`. Throws
|
|
6224
|
+
* `TamerReferenceError` (with field path) if any
|
|
6225
|
+
* reference is unresolved — bubbles up to the caller as a fatal.
|
|
6226
|
+
*/
|
|
6227
|
+
function resolveCrossResourceReferences(merged, ctx) {
|
|
6228
|
+
const refCtx = {
|
|
6229
|
+
config: ctx.config,
|
|
6230
|
+
env: ctx.env,
|
|
6231
|
+
state: ctx.state,
|
|
6232
|
+
naming: ctx.naming,
|
|
6233
|
+
tolerant: ctx.tolerant,
|
|
6234
|
+
imports: ctx.imports,
|
|
6235
|
+
accountId: ctx.accountId
|
|
6236
|
+
};
|
|
6237
|
+
const m = merged;
|
|
6238
|
+
let next = merged;
|
|
6239
|
+
if (m.vars) {
|
|
6240
|
+
const resolvedVars = resolveReferencesInVars(materializeVars(m.vars), refCtx, `worker[${ctx.workerKey}].vars`);
|
|
6241
|
+
if (resolvedVars && resolvedVars !== m.vars) next = {
|
|
6242
|
+
...next,
|
|
6243
|
+
vars: resolvedVars
|
|
6244
|
+
};
|
|
6245
|
+
}
|
|
6246
|
+
const tamerRoutes = next.tamerRoutes;
|
|
6247
|
+
if (tamerRoutes && tamerRoutes.length > 0) {
|
|
6248
|
+
let mutated = false;
|
|
6249
|
+
const resolvedRoutes = tamerRoutes.map((r, idx) => {
|
|
6250
|
+
const fieldBase = `worker[${ctx.workerKey}].tamerRoutes[${idx}]`;
|
|
6251
|
+
const host = resolveReferencesInString(r.host, refCtx, `${fieldBase}.host`);
|
|
6252
|
+
const zone = r.zone ? resolveReferencesInString(r.zone, refCtx, `${fieldBase}.zone`) : r.zone;
|
|
6253
|
+
if (host !== r.host || zone !== r.zone) mutated = true;
|
|
6254
|
+
return {
|
|
6255
|
+
...r,
|
|
6256
|
+
host,
|
|
6257
|
+
...zone !== void 0 ? { zone } : {}
|
|
6258
|
+
};
|
|
6259
|
+
});
|
|
6260
|
+
if (mutated) next = {
|
|
6261
|
+
...next,
|
|
6262
|
+
tamerRoutes: resolvedRoutes
|
|
6263
|
+
};
|
|
6264
|
+
}
|
|
6265
|
+
const r2Buckets = next.r2_buckets;
|
|
6266
|
+
if (r2Buckets && r2Buckets.length > 0) {
|
|
6267
|
+
let mutated = false;
|
|
6268
|
+
const resolvedBuckets = r2Buckets.map((b, idx) => {
|
|
6269
|
+
const raw = b.bucket_name;
|
|
6270
|
+
if (raw === void 0) return b;
|
|
6271
|
+
const fieldBase = `worker[${ctx.workerKey}].r2_buckets[${idx}].bucket_name`;
|
|
6272
|
+
const bucket_name = resolveReferencesInString(materializeTamerResolvable(raw), refCtx, fieldBase);
|
|
6273
|
+
if (bucket_name !== raw) mutated = true;
|
|
6274
|
+
return {
|
|
6275
|
+
...b,
|
|
6276
|
+
bucket_name
|
|
6277
|
+
};
|
|
6278
|
+
});
|
|
6279
|
+
if (mutated) next = {
|
|
6280
|
+
...next,
|
|
6281
|
+
r2_buckets: resolvedBuckets
|
|
6282
|
+
};
|
|
6283
|
+
}
|
|
6284
|
+
const svc = next.services;
|
|
6285
|
+
if (svc && svc.length > 0) {
|
|
6286
|
+
let mutated = false;
|
|
6287
|
+
const resolvedSvc = svc.map((s, idx) => {
|
|
6288
|
+
const raw = s.service;
|
|
6289
|
+
const fieldBase = `worker[${ctx.workerKey}].services[${idx}].service`;
|
|
6290
|
+
const service = resolveReferencesInString(materializeTamerResolvable(raw), refCtx, fieldBase);
|
|
6291
|
+
if (service !== raw) mutated = true;
|
|
6292
|
+
return {
|
|
6293
|
+
...s,
|
|
6294
|
+
service
|
|
6295
|
+
};
|
|
6296
|
+
});
|
|
6297
|
+
if (mutated) next = {
|
|
6298
|
+
...next,
|
|
6299
|
+
services: resolvedSvc
|
|
6300
|
+
};
|
|
6301
|
+
}
|
|
6302
|
+
const dispatchNsMerged = next.dispatch_namespaces;
|
|
6303
|
+
if (dispatchNsMerged && dispatchNsMerged.length > 0) {
|
|
6304
|
+
let mutated = false;
|
|
6305
|
+
const resolvedDn = dispatchNsMerged.map((d, idx) => {
|
|
6306
|
+
const raw = d.namespace;
|
|
6307
|
+
const fieldBase = `worker[${ctx.workerKey}].dispatch_namespaces[${idx}].namespace`;
|
|
6308
|
+
const namespace = resolveReferencesInString(materializeTamerResolvable(raw), refCtx, fieldBase);
|
|
6309
|
+
if (namespace !== raw) mutated = true;
|
|
6310
|
+
return {
|
|
6311
|
+
...d,
|
|
6312
|
+
namespace
|
|
6313
|
+
};
|
|
6314
|
+
});
|
|
6315
|
+
if (mutated) next = {
|
|
6316
|
+
...next,
|
|
6317
|
+
dispatch_namespaces: resolvedDn
|
|
6318
|
+
};
|
|
6319
|
+
}
|
|
6320
|
+
const resBlock = next.resources;
|
|
6321
|
+
if (resBlock?.d1 && resBlock.d1.length > 0) {
|
|
6322
|
+
let mutated = false;
|
|
6323
|
+
const d1Resolved = resBlock.d1.map((d1c, idx) => {
|
|
6324
|
+
if (d1c.ownership !== "external" || !d1c.databaseName) return d1c;
|
|
6325
|
+
const fieldBase = `worker[${ctx.workerKey}].resources.d1[${idx}].databaseName`;
|
|
6326
|
+
const databaseName = resolveReferencesInString(materializeTamerResolvable(d1c.databaseName), refCtx, fieldBase);
|
|
6327
|
+
if (databaseName !== d1c.databaseName) mutated = true;
|
|
6328
|
+
return {
|
|
6329
|
+
...d1c,
|
|
6330
|
+
databaseName
|
|
6331
|
+
};
|
|
6332
|
+
});
|
|
6333
|
+
if (mutated) next = {
|
|
6334
|
+
...next,
|
|
6335
|
+
resources: {
|
|
6336
|
+
...resBlock,
|
|
6337
|
+
d1: d1Resolved
|
|
6338
|
+
}
|
|
6339
|
+
};
|
|
6340
|
+
}
|
|
6341
|
+
return next;
|
|
6342
|
+
}
|
|
6343
|
+
function stripTamerFields(config$1) {
|
|
6344
|
+
const { path, config: configPath, resources, local, env, scriptName: _scriptName, wranglerOutFile: _out, dispatchNamespace: _dispatchNs, tamerRoutes: _tamerRoutes, tamerStaleRouteSweepZones: _tamerStaleRouteSweepZones, ...rest$1 } = config$1;
|
|
6345
|
+
return rest$1;
|
|
6346
|
+
}
|
|
6347
|
+
/**
|
|
6348
|
+
* Env merge + intra-stack `services` rewrite + sibling-stack resolution for
|
|
6349
|
+
* **`resources.d1[].databaseName` only** when `ownership: "external"`.
|
|
6350
|
+
*
|
|
6351
|
+
* Use before {@link ResourceModule.pickResources} during `apply` / `sync` /
|
|
6352
|
+
* `drift` so imported D1 names are known **without** resolving worker `vars`
|
|
6353
|
+
* (those references often need resources created earlier in the same `apply`).
|
|
6354
|
+
*/
|
|
6355
|
+
function mergeWorkerConfigForResourcePick(config$1, workerKey, workerConfig, env, accountId, naming, state, opts = {}) {
|
|
6356
|
+
let merged = mergedWorkerConfigForEnv(workerConfig, env, config$1.tenant);
|
|
6357
|
+
merged = applyDispatchNamespaceEnvOverrides(config$1, merged, env);
|
|
6358
|
+
const intraMap = buildIntraStackScriptNameMap(config$1, env, naming);
|
|
6359
|
+
merged = rewriteIntraStackServiceTargets(merged, intraMap);
|
|
6360
|
+
const refCtx = {
|
|
6361
|
+
config: config$1,
|
|
6362
|
+
env,
|
|
6363
|
+
state,
|
|
6364
|
+
naming,
|
|
6365
|
+
tolerant: opts.referencesMode === "tolerant",
|
|
6366
|
+
imports: opts.imports,
|
|
6367
|
+
accountId
|
|
6368
|
+
};
|
|
6369
|
+
const resBlock = merged.resources;
|
|
6370
|
+
if (!resBlock?.d1?.length) return merged;
|
|
6371
|
+
const d1Resolved = resBlock.d1.map((d1c, idx) => {
|
|
6372
|
+
if (d1c.ownership !== "external" || !d1c.databaseName) return d1c;
|
|
6373
|
+
const fieldBase = `worker[${workerKey}].resources.d1[${idx}].databaseName`;
|
|
6374
|
+
const databaseName = resolveReferencesInString(materializeTamerResolvable(d1c.databaseName), refCtx, fieldBase);
|
|
6375
|
+
return {
|
|
6376
|
+
...d1c,
|
|
6377
|
+
databaseName
|
|
6378
|
+
};
|
|
6379
|
+
});
|
|
6380
|
+
return {
|
|
6381
|
+
...merged,
|
|
6382
|
+
resources: {
|
|
6383
|
+
...resBlock,
|
|
6384
|
+
d1: d1Resolved
|
|
6385
|
+
}
|
|
6386
|
+
};
|
|
6387
|
+
}
|
|
6388
|
+
/**
|
|
6389
|
+
* Env-merged worker config with every `${tamer:…}` site resolved for wrangler
|
|
6390
|
+
* (`vars`, `tamerRoutes`, `r2_buckets[].bucket_name`, external D1 names).
|
|
6391
|
+
* Used by {@link resolveWorkerConfig} after resource state is up to date.
|
|
6392
|
+
*/
|
|
6393
|
+
function mergeWorkerConfigWithResolvedRefs(config$1, workerKey, workerConfig, env, accountId, naming, state, opts = {}) {
|
|
6394
|
+
let merged = mergedWorkerConfigForEnv(workerConfig, env, config$1.tenant);
|
|
6395
|
+
merged = applyDispatchNamespaceEnvOverrides(config$1, merged, env);
|
|
6396
|
+
const intraMap = buildIntraStackScriptNameMap(config$1, env, naming);
|
|
6397
|
+
merged = rewriteIntraStackServiceTargets(merged, intraMap);
|
|
6398
|
+
return resolveCrossResourceReferences(merged, {
|
|
6399
|
+
config: config$1,
|
|
6400
|
+
env,
|
|
6401
|
+
state,
|
|
6402
|
+
naming,
|
|
6403
|
+
workerKey,
|
|
6404
|
+
tolerant: opts.referencesMode === "tolerant",
|
|
6405
|
+
imports: opts.imports,
|
|
6406
|
+
accountId
|
|
6407
|
+
});
|
|
6408
|
+
}
|
|
6409
|
+
async function resolveWorkerConfig(config$1, workerKey, workerConfig, env, baseDir, accountId, naming, state, opts = {}) {
|
|
6410
|
+
const merged = mergeWorkerConfigWithResolvedRefs(config$1, workerKey, workerConfig, env, accountId, naming, state, opts);
|
|
6411
|
+
const workerDir = workerConfig.path ? resolve(baseDir, workerConfig.path) : baseDir;
|
|
6412
|
+
const m = merged;
|
|
6413
|
+
const workerName = resolveDeployedWorkerName(config$1, workerKey, merged, env, naming);
|
|
6414
|
+
const wranglerOutFile = assertSafeWranglerOutFile(m.wranglerOutFile?.trim() || "wrangler.json");
|
|
6415
|
+
const dispatchNamespace = m.dispatchNamespace?.trim() || void 0;
|
|
6416
|
+
const stripped = stripTamerFields(merged);
|
|
6417
|
+
const tamerRoutes = merged.tamerRoutes;
|
|
6418
|
+
const expandedRoutes = effectiveRoutesForEnv(tamerRoutes, env);
|
|
6419
|
+
const apiManagedRoutes = expandedRoutes.filter(isApiManagedZoneRoute);
|
|
6420
|
+
const wranglerTamerRoutes = expandedRoutes.filter(isWranglerOnlyTamerRoute);
|
|
6421
|
+
const mergedRoutes = [...stripped.routes ?? [], ...wranglerTamerRoutes];
|
|
6422
|
+
/** Non-local deploys emit `routes: []` when there are none — omitting `routes` can leave stale Wrangler-published custom domains attached from a prior config. */
|
|
6423
|
+
const wranglerRoutes = mergedRoutes.length > 0 ? mergedRoutes : env === "local" ? void 0 : [];
|
|
6424
|
+
return {
|
|
6425
|
+
workerKey,
|
|
6426
|
+
workerName,
|
|
6427
|
+
workerDir,
|
|
6428
|
+
env,
|
|
6429
|
+
wranglerOutFile,
|
|
6430
|
+
dispatchNamespace,
|
|
6431
|
+
wranglerConfig: {
|
|
6432
|
+
...stripped,
|
|
6433
|
+
...wranglerRoutes !== void 0 ? { routes: wranglerRoutes } : {},
|
|
6434
|
+
name: workerName,
|
|
6435
|
+
account_id: accountId,
|
|
6436
|
+
compatibility_date: stripped.compatibility_date ?? config$1.compatibility_date
|
|
6437
|
+
},
|
|
6438
|
+
resources: merged.resources ?? workerConfig.resources ?? {},
|
|
6439
|
+
apiManagedRoutes
|
|
6440
|
+
};
|
|
6441
|
+
}
|
|
6442
|
+
|
|
6443
|
+
//#endregion
|
|
6444
|
+
//#region src/core/state/stateSchema.ts
|
|
6445
|
+
const D1StateEntrySchema = object({
|
|
6446
|
+
type: literal("d1_database"),
|
|
6447
|
+
logicalName: string(),
|
|
6448
|
+
shardDate: string().optional(),
|
|
6449
|
+
derivedName: string(),
|
|
6450
|
+
bindingKey: string(),
|
|
6451
|
+
cfId: string(),
|
|
6452
|
+
migrationsDir: string().optional(),
|
|
6453
|
+
preserveOnDestroy: boolean().optional(),
|
|
6454
|
+
createdAt: string(),
|
|
6455
|
+
updatedAt: string()
|
|
6456
|
+
});
|
|
6457
|
+
const R2StateEntrySchema = object({
|
|
6458
|
+
type: literal("r2_bucket"),
|
|
6459
|
+
logicalName: string(),
|
|
6460
|
+
createdDate: string(),
|
|
6461
|
+
derivedName: string(),
|
|
6462
|
+
bindingKey: string(),
|
|
6463
|
+
createdAt: string(),
|
|
6464
|
+
updatedAt: string()
|
|
6465
|
+
});
|
|
6466
|
+
const KVStateEntrySchema = object({
|
|
6467
|
+
type: literal("kv_namespace"),
|
|
6468
|
+
logicalName: string(),
|
|
6469
|
+
derivedName: string(),
|
|
6470
|
+
bindingKey: string(),
|
|
6471
|
+
cfId: string(),
|
|
6472
|
+
createdAt: string(),
|
|
6473
|
+
updatedAt: string()
|
|
6474
|
+
});
|
|
6475
|
+
const QueueStateEntrySchema = object({
|
|
6476
|
+
type: literal("queue"),
|
|
6477
|
+
logicalName: string(),
|
|
6478
|
+
derivedName: string(),
|
|
6479
|
+
bindingKey: string(),
|
|
6480
|
+
cfId: string(),
|
|
6481
|
+
producerBinding: boolean(),
|
|
6482
|
+
createdAt: string(),
|
|
6483
|
+
updatedAt: string()
|
|
6484
|
+
});
|
|
6485
|
+
const VectorizeStateEntrySchema = object({
|
|
6486
|
+
type: literal("vectorize"),
|
|
6487
|
+
logicalName: string(),
|
|
6488
|
+
derivedName: string(),
|
|
6489
|
+
bindingKey: string(),
|
|
6490
|
+
cfId: string(),
|
|
6491
|
+
dimensions: number$1(),
|
|
6492
|
+
metric: _enum([
|
|
6493
|
+
"cosine",
|
|
6494
|
+
"euclidean",
|
|
6495
|
+
"dot-product"
|
|
6496
|
+
]),
|
|
6497
|
+
createdAt: string(),
|
|
6498
|
+
updatedAt: string()
|
|
6499
|
+
});
|
|
6500
|
+
const AIGatewayStateEntrySchema = object({
|
|
6501
|
+
type: literal("ai_gateway"),
|
|
6502
|
+
logicalName: string(),
|
|
6503
|
+
derivedName: string(),
|
|
6504
|
+
bindingKey: string(),
|
|
6505
|
+
cfId: string(),
|
|
6506
|
+
cacheTtl: number$1(),
|
|
6507
|
+
cacheInvalidateOnUpdate: boolean(),
|
|
6508
|
+
collectLogs: boolean(),
|
|
6509
|
+
authentication: boolean(),
|
|
6510
|
+
rateLimitingInterval: number$1(),
|
|
6511
|
+
rateLimitingLimit: number$1(),
|
|
6512
|
+
rateLimitingTechnique: _enum(["fixed", "sliding"]),
|
|
6513
|
+
createdAt: string(),
|
|
6514
|
+
updatedAt: string()
|
|
6515
|
+
});
|
|
6516
|
+
const PipelineStateEntrySchema = object({
|
|
6517
|
+
type: literal("pipeline"),
|
|
6518
|
+
logicalName: string(),
|
|
6519
|
+
derivedName: string(),
|
|
6520
|
+
bindingKey: string(),
|
|
6521
|
+
cfId: string(),
|
|
6522
|
+
sql: string(),
|
|
6523
|
+
status: string().optional(),
|
|
6524
|
+
createdAt: string(),
|
|
6525
|
+
updatedAt: string()
|
|
6526
|
+
});
|
|
6527
|
+
const WorkflowStateEntrySchema = object({
|
|
6528
|
+
type: literal("workflow"),
|
|
6529
|
+
logicalName: string(),
|
|
6530
|
+
derivedName: string(),
|
|
6531
|
+
bindingKey: string(),
|
|
6532
|
+
cfId: string(),
|
|
6533
|
+
className: string(),
|
|
6534
|
+
scriptName: string(),
|
|
6535
|
+
limits: object({ steps: number$1().int().positive().optional() }).optional(),
|
|
6536
|
+
createdAt: string(),
|
|
6537
|
+
updatedAt: string()
|
|
6538
|
+
});
|
|
6539
|
+
const SecretsStoreStateEntrySchema = object({
|
|
6540
|
+
type: literal("secrets_store"),
|
|
6541
|
+
logicalName: string(),
|
|
6542
|
+
derivedName: string(),
|
|
6543
|
+
bindingKey: string(),
|
|
6544
|
+
cfId: string(),
|
|
6545
|
+
createdAt: string(),
|
|
6546
|
+
updatedAt: string()
|
|
6547
|
+
});
|
|
6548
|
+
const HyperdriveStateEntrySchema = object({
|
|
6549
|
+
type: literal("hyperdrive"),
|
|
6550
|
+
logicalName: string(),
|
|
6551
|
+
derivedName: string(),
|
|
6552
|
+
bindingKey: string(),
|
|
6553
|
+
cfId: string(),
|
|
6554
|
+
scheme: _enum([
|
|
6555
|
+
"postgres",
|
|
6556
|
+
"postgresql",
|
|
6557
|
+
"mysql"
|
|
6558
|
+
]),
|
|
6559
|
+
originHost: string(),
|
|
6560
|
+
originDatabase: string(),
|
|
6561
|
+
createdAt: string(),
|
|
6562
|
+
updatedAt: string()
|
|
6563
|
+
});
|
|
6564
|
+
const DnsRecordTypeSchema = _enum([
|
|
6565
|
+
"A",
|
|
6566
|
+
"AAAA",
|
|
6567
|
+
"CNAME",
|
|
6568
|
+
"TXT",
|
|
6569
|
+
"MX",
|
|
6570
|
+
"NS",
|
|
6571
|
+
"CAA",
|
|
6572
|
+
"SRV",
|
|
6573
|
+
"PTR",
|
|
6574
|
+
"HTTPS",
|
|
6575
|
+
"SVCB"
|
|
6576
|
+
]);
|
|
6577
|
+
const DnsRecordStateEntrySchema = object({
|
|
6578
|
+
type: literal("dns_record"),
|
|
6579
|
+
logicalName: string(),
|
|
6580
|
+
zoneId: string(),
|
|
6581
|
+
recordType: DnsRecordTypeSchema,
|
|
6582
|
+
name: string(),
|
|
6583
|
+
content: string(),
|
|
6584
|
+
ttl: number$1(),
|
|
6585
|
+
proxied: boolean(),
|
|
6586
|
+
priority: number$1().optional(),
|
|
6587
|
+
comment: string(),
|
|
6588
|
+
recordId: string(),
|
|
6589
|
+
createdAt: string(),
|
|
6590
|
+
updatedAt: string()
|
|
6591
|
+
});
|
|
6592
|
+
const DispatchNamespaceStateEntrySchema = object({
|
|
6593
|
+
type: literal("dispatch_namespace"),
|
|
6594
|
+
logicalName: string(),
|
|
6595
|
+
derivedName: string(),
|
|
6596
|
+
createdAt: string(),
|
|
6597
|
+
updatedAt: string()
|
|
6598
|
+
});
|
|
6599
|
+
const LogpushJobStateEntrySchema = object({
|
|
6600
|
+
type: literal("logpush_job"),
|
|
6601
|
+
logicalName: string(),
|
|
6602
|
+
derivedName: string(),
|
|
6603
|
+
cfJobId: number$1(),
|
|
6604
|
+
dataset: string(),
|
|
6605
|
+
createdAt: string(),
|
|
6606
|
+
updatedAt: string()
|
|
6607
|
+
});
|
|
6608
|
+
const LogpushPipelinesStateEntrySchema = object({
|
|
6609
|
+
type: literal("logpush_pipelines"),
|
|
6610
|
+
logicalName: string(),
|
|
6611
|
+
streamId: string(),
|
|
6612
|
+
streamIngestBaseUrl: string().optional(),
|
|
6613
|
+
sinkId: string(),
|
|
6614
|
+
pipelineId: string(),
|
|
6615
|
+
streamName: string(),
|
|
6616
|
+
sinkName: string(),
|
|
6617
|
+
pipelineName: string(),
|
|
6618
|
+
r2DataCatalogTableName: string().optional(),
|
|
6619
|
+
r2DataCatalogTableNamePipelines: string().optional(),
|
|
6620
|
+
r2DataCatalogNamespace: string().optional(),
|
|
6621
|
+
catalogBucketDerivedName: string(),
|
|
6622
|
+
mintedR2CatalogTokenId: string().optional(),
|
|
6623
|
+
mintedR2CatalogTokenValue: string().optional(),
|
|
6624
|
+
mintedPipelinesSendTokenId: string().optional(),
|
|
6625
|
+
mintedPipelinesSendTokenValue: string().optional(),
|
|
6626
|
+
createdAt: string(),
|
|
6627
|
+
updatedAt: string()
|
|
6628
|
+
});
|
|
6629
|
+
const WorkerRouteStateEntrySchema = object({
|
|
6630
|
+
type: literal("worker_route"),
|
|
6631
|
+
workerKey: string(),
|
|
6632
|
+
workerName: string(),
|
|
6633
|
+
zoneId: string(),
|
|
6634
|
+
zoneName: string(),
|
|
6635
|
+
routeId: string(),
|
|
6636
|
+
pattern: string(),
|
|
6637
|
+
createdAt: string(),
|
|
6638
|
+
updatedAt: string()
|
|
6639
|
+
});
|
|
6640
|
+
const SecretStateEntrySchema = object({
|
|
6641
|
+
type: literal("secret"),
|
|
6642
|
+
worker: string(),
|
|
6643
|
+
name: string(),
|
|
6644
|
+
lastPushedHash: string(),
|
|
6645
|
+
lastPushedAt: string()
|
|
6646
|
+
});
|
|
6647
|
+
const StateEntrySchema = discriminatedUnion("type", [
|
|
6648
|
+
D1StateEntrySchema,
|
|
6649
|
+
R2StateEntrySchema,
|
|
6650
|
+
KVStateEntrySchema,
|
|
6651
|
+
QueueStateEntrySchema,
|
|
6652
|
+
HyperdriveStateEntrySchema,
|
|
6653
|
+
VectorizeStateEntrySchema,
|
|
6654
|
+
AIGatewayStateEntrySchema,
|
|
6655
|
+
PipelineStateEntrySchema,
|
|
6656
|
+
WorkflowStateEntrySchema,
|
|
6657
|
+
SecretsStoreStateEntrySchema,
|
|
6658
|
+
DnsRecordStateEntrySchema,
|
|
6659
|
+
DispatchNamespaceStateEntrySchema,
|
|
6660
|
+
LogpushJobStateEntrySchema,
|
|
6661
|
+
LogpushPipelinesStateEntrySchema,
|
|
6662
|
+
WorkerRouteStateEntrySchema,
|
|
6663
|
+
SecretStateEntrySchema
|
|
6664
|
+
]);
|
|
6665
|
+
const ProvisioningStatusSchema = _enum([
|
|
6666
|
+
"pending",
|
|
6667
|
+
"d1_created",
|
|
6668
|
+
"migrations_applied",
|
|
6669
|
+
"script_uploaded",
|
|
6670
|
+
"ready",
|
|
6671
|
+
"tombstoned"
|
|
6672
|
+
]);
|
|
6673
|
+
const TenantD1ShardRefSchema = object({
|
|
6674
|
+
role: string(),
|
|
6675
|
+
derivedName: string(),
|
|
6676
|
+
cfId: string()
|
|
6677
|
+
});
|
|
6678
|
+
const TenantStateEntrySchema = object({
|
|
6679
|
+
product: string(),
|
|
6680
|
+
workspace: string(),
|
|
6681
|
+
provisioningStatus: ProvisioningStatusSchema,
|
|
6682
|
+
dispatchNamespaceName: string(),
|
|
6683
|
+
scriptName: string(),
|
|
6684
|
+
d1Shards: array(TenantD1ShardRefSchema).optional(),
|
|
6685
|
+
createdAt: string(),
|
|
6686
|
+
updatedAt: string()
|
|
6687
|
+
});
|
|
6688
|
+
const CfiStackMetaSchema = object({
|
|
6689
|
+
name: string().optional(),
|
|
6690
|
+
owner: string().optional()
|
|
6691
|
+
});
|
|
6692
|
+
const CfiOperationNameSchema = _enum([
|
|
6693
|
+
"bootstrap",
|
|
6694
|
+
"apply",
|
|
6695
|
+
"deploy",
|
|
6696
|
+
"destroy",
|
|
6697
|
+
"provision-tenant",
|
|
6698
|
+
"destroy-tenant",
|
|
6699
|
+
"import",
|
|
6700
|
+
"sync"
|
|
6701
|
+
]);
|
|
6702
|
+
const CfiOperationStatusSchema = _enum([
|
|
6703
|
+
"in_progress",
|
|
6704
|
+
"succeeded",
|
|
6705
|
+
"failed"
|
|
6706
|
+
]);
|
|
6707
|
+
const CfiStackOutputValueSchema = object({
|
|
6708
|
+
value: string(),
|
|
6709
|
+
source: string(),
|
|
6710
|
+
resolvedAt: string()
|
|
6711
|
+
});
|
|
6712
|
+
const CfiOperationRecordSchema = object({
|
|
6713
|
+
command: CfiOperationNameSchema,
|
|
6714
|
+
status: CfiOperationStatusSchema,
|
|
6715
|
+
startedAt: string(),
|
|
6716
|
+
completedAt: string().optional(),
|
|
6717
|
+
errorMessage: string().optional(),
|
|
6718
|
+
detail: string().optional()
|
|
6719
|
+
});
|
|
6720
|
+
const CfiStateSchema = object({
|
|
6721
|
+
tenantId: string(),
|
|
6722
|
+
env: string(),
|
|
6723
|
+
schemaVersion: number$1(),
|
|
6724
|
+
syncedAt: string(),
|
|
6725
|
+
resources: record(string(), StateEntrySchema),
|
|
6726
|
+
revision: number$1().optional(),
|
|
6727
|
+
tenants: record(string(), TenantStateEntrySchema).optional(),
|
|
6728
|
+
stack: CfiStackMetaSchema.optional(),
|
|
6729
|
+
stackOutputs: record(string(), CfiStackOutputValueSchema).optional(),
|
|
6730
|
+
lastOperation: CfiOperationRecordSchema.optional(),
|
|
6731
|
+
operationHistory: array(CfiOperationRecordSchema).optional()
|
|
6732
|
+
});
|
|
6733
|
+
|
|
6734
|
+
//#endregion
|
|
6735
|
+
//#region src/core/state/stackName.ts
|
|
6736
|
+
const DEFAULT_STACK_NAME = "default";
|
|
6737
|
+
function stackNameForConfig(config$1) {
|
|
6738
|
+
return config$1.stack?.name ?? config$1.tenant.slug ?? DEFAULT_STACK_NAME;
|
|
6739
|
+
}
|
|
6740
|
+
|
|
6741
|
+
//#endregion
|
|
6742
|
+
//#region src/core/state/tamerStateDb.ts
|
|
6743
|
+
/**
|
|
6744
|
+
* Schema versions:
|
|
6745
|
+
* 2: original (resources only).
|
|
6746
|
+
* 3: + `tenants` map, + `revision` for optimistic concurrency.
|
|
6747
|
+
* 4: + `stack` metadata, + `lastOperation` (CloudFormation-style stack info).
|
|
6748
|
+
* All v4 additions are optional; existing v3 documents are upgraded
|
|
6749
|
+
* in-place during parse with no data loss.
|
|
6750
|
+
* 5: + `secret` state entries (fingerprints only; additive resource rows).
|
|
6751
|
+
*/
|
|
6752
|
+
const STATE_SCHEMA_VERSION = 5;
|
|
6753
|
+
/** Cloudflare D1 database that holds JSON state for an env (`tamer-state-dev`, …). */
|
|
6754
|
+
function tamerStateDatabaseName(env) {
|
|
6755
|
+
return `tamer-state-${env}`;
|
|
6756
|
+
}
|
|
6757
|
+
function createEmptyCfiState(tenantId, env) {
|
|
6758
|
+
return {
|
|
6759
|
+
tenantId,
|
|
6760
|
+
env,
|
|
6761
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
6762
|
+
syncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6763
|
+
resources: {},
|
|
6764
|
+
revision: 0,
|
|
6765
|
+
tenants: {}
|
|
6766
|
+
};
|
|
6767
|
+
}
|
|
6768
|
+
/** In-place upgrade for parsed JSON before Zod validation. */
|
|
6769
|
+
function migrateRawCfiStateInPlace(raw) {
|
|
6770
|
+
const v = raw.schemaVersion;
|
|
6771
|
+
if (typeof v !== "number") throw new Error("tamer state: schemaVersion must be a number");
|
|
6772
|
+
if (v < 2) throw new Error(`tamer state: unsupported schemaVersion ${v}`);
|
|
6773
|
+
if (v > STATE_SCHEMA_VERSION) throw new Error(`tamer state: unknown schemaVersion ${v} (engine supports up to ${STATE_SCHEMA_VERSION})`);
|
|
6774
|
+
if (v === 2) {
|
|
6775
|
+
raw.tenants = {};
|
|
6776
|
+
raw.revision = 0;
|
|
6777
|
+
raw.schemaVersion = 3;
|
|
6778
|
+
}
|
|
6779
|
+
if (!raw.tenants || typeof raw.tenants !== "object") raw.tenants = {};
|
|
6780
|
+
if (typeof raw.revision !== "number") raw.revision = 0;
|
|
6781
|
+
if (raw.schemaVersion === 3) raw.schemaVersion = 4;
|
|
6782
|
+
if (raw.schemaVersion === 4) raw.schemaVersion = STATE_SCHEMA_VERSION;
|
|
6783
|
+
}
|
|
6784
|
+
async function findTamerStateDatabaseUuid(api, env) {
|
|
6785
|
+
const name = tamerStateDatabaseName(env);
|
|
6786
|
+
return (await api.d1ListAll()).find((d) => d.name === name)?.uuid;
|
|
6787
|
+
}
|
|
6788
|
+
/**
|
|
6789
|
+
* Create `tamer-state-{env}` if missing, ensure `tamer_kv` table, and seed an
|
|
6790
|
+
* initial empty `cfi_state:{stackName}` row when this stack has no row yet.
|
|
6791
|
+
* Idempotent — re-running for the same stack is a no-op; re-running for a
|
|
6792
|
+
* different stack against the same env D1 just adds another row.
|
|
6793
|
+
*/
|
|
6794
|
+
async function ensureTamerStateDatabase(api, tenantId, env, stackName = DEFAULT_STACK_NAME) {
|
|
6795
|
+
let uuid$1 = await findTamerStateDatabaseUuid(api, env);
|
|
6796
|
+
if (!uuid$1) uuid$1 = (await api.d1Create(tamerStateDatabaseName(env))).uuid;
|
|
6797
|
+
await api.d1Query(uuid$1, `CREATE TABLE IF NOT EXISTS tamer_kv (
|
|
6798
|
+
k TEXT PRIMARY KEY,
|
|
6799
|
+
v TEXT NOT NULL
|
|
6800
|
+
)`);
|
|
6801
|
+
const rowKey = `cfi_state:${stackName}`;
|
|
6802
|
+
const { rows } = await api.d1Query(uuid$1, `SELECT v FROM tamer_kv WHERE k = ?`, [rowKey]);
|
|
6803
|
+
if (rows.length === 0) {
|
|
6804
|
+
const initial = createEmptyCfiState(tenantId, env);
|
|
6805
|
+
await api.d1Query(uuid$1, `INSERT INTO tamer_kv (k, v) VALUES (?, ?)`, [rowKey, JSON.stringify(initial)]);
|
|
6806
|
+
}
|
|
6807
|
+
return uuid$1;
|
|
6808
|
+
}
|
|
6809
|
+
function parseCfiStateJson(json) {
|
|
6810
|
+
const raw = JSON.parse(json);
|
|
6811
|
+
migrateRawCfiStateInPlace(raw);
|
|
6812
|
+
const result = CfiStateSchema.safeParse(raw);
|
|
6813
|
+
if (!result.success) throw new Error(`Invalid tamer state JSON: ${result.error.message}`);
|
|
6814
|
+
return result.data;
|
|
6815
|
+
}
|
|
6816
|
+
async function destroyTamerStateDatabase(api, env) {
|
|
6817
|
+
const uuid$1 = await findTamerStateDatabaseUuid(api, env);
|
|
6818
|
+
if (!uuid$1) return false;
|
|
6819
|
+
await api.d1Delete(uuid$1);
|
|
6820
|
+
return true;
|
|
6821
|
+
}
|
|
6822
|
+
|
|
6823
|
+
//#endregion
|
|
6824
|
+
//#region src/core/tenant/tenantKeys.ts
|
|
6825
|
+
/** Stable map key for `CfiState.tenants` (workspace-scoped product tenant). */
|
|
6826
|
+
function tenantStateKey(product, workspace) {
|
|
6827
|
+
return `${product}:${workspace}`;
|
|
6828
|
+
}
|
|
6829
|
+
/**
|
|
6830
|
+
* Dispatch-namespace script name per `docs/handoff.md` §6: non-ephemeral
|
|
6831
|
+
* envs collapse to `{product}-{workspace}` (one script per workspace);
|
|
6832
|
+
* ephemeral envs (matching `tenant.ephemeralEnvPattern`) carry the env
|
|
6833
|
+
* in the script name (`{product}-{workspace}-{env}`) so multiple
|
|
6834
|
+
* previews can coexist in the shared `{ns}-ephemeral` namespace.
|
|
6835
|
+
*/
|
|
6836
|
+
function tenantDispatchScriptName(product, workspace, env, tenant) {
|
|
6837
|
+
if (isEphemeralEnv(env, tenant)) return `${product}-${workspace}-${env}`;
|
|
6838
|
+
return `${product}-${workspace}`;
|
|
6839
|
+
}
|
|
6840
|
+
const SAFE = /[^a-z0-9_-]/gi;
|
|
6841
|
+
/**
|
|
6842
|
+
* Per-shard D1 database name for a tenant. Stable across `provision-tenant`
|
|
6843
|
+
* runs and across env so re-provisioning + drift detection can match by
|
|
6844
|
+
* name. Format: `db_{role}_{w}_{p}_t_{tid}_{env}`.
|
|
6845
|
+
*
|
|
6846
|
+
* db_system_acme_todo_t_platform_prod
|
|
6847
|
+
* db_app_acme_todo_t_platform_prod
|
|
6848
|
+
*
|
|
6849
|
+
* `role` is whatever the operator declared in `tenant.d1Shards` in
|
|
6850
|
+
* `tamer.config.ts`. Tamer is opinion-free about the shard layout — a
|
|
6851
|
+
* Dragoncore-style product picks `["system", "app", "history"]`, a
|
|
6852
|
+
* single-DB tenant picks `["main"]`, an audit-only tenant picks
|
|
6853
|
+
* `["audit"]`, etc. The role itself is validated by the loader (lowercase
|
|
6854
|
+
* ASCII subset) so it slots cleanly into the D1 naming scheme.
|
|
6855
|
+
*
|
|
6856
|
+
* D1 names are length-bounded (Cloudflare currently allows up to 64
|
|
6857
|
+
* chars), and this scheme keeps every shard well under that limit even
|
|
6858
|
+
* for long workspace + product slugs.
|
|
6859
|
+
*/
|
|
6860
|
+
function tenantShardDatabaseName(role, workspace, product, platformTenantId, env) {
|
|
6861
|
+
return `db_${role}_${workspace.replace(SAFE, "_").toLowerCase()}_${product.replace(SAFE, "_").toLowerCase()}_t_${platformTenantId}_${env}`;
|
|
6862
|
+
}
|
|
6863
|
+
/**
|
|
6864
|
+
* Parse + validate a `--shards a,b,c` CLI argument against the configured
|
|
6865
|
+
* shard set in `tamer.config.ts`. The CLI flag may only **trim** the
|
|
6866
|
+
* configured layout (e.g. `--shards system` on a stack whose config
|
|
6867
|
+
* declares `["system","app","history"]` provisions just the system
|
|
6868
|
+
* shard for an ephemeral preview); it cannot extend it, because the
|
|
6869
|
+
* config is the source of truth that `apply` / `drift` / `destroy`
|
|
6870
|
+
* other operators read.
|
|
6871
|
+
*
|
|
6872
|
+
* Returns the requested roles in canonical order (matches `allowed`
|
|
6873
|
+
* order, regardless of input order) so plan/apply output is
|
|
6874
|
+
* deterministic and partial-failure resumes pick up at the next
|
|
6875
|
+
* canonical role.
|
|
6876
|
+
*/
|
|
6877
|
+
function parseTenantShardRoles(raw, allowed) {
|
|
6878
|
+
const allowedSet = new Set(allowed);
|
|
6879
|
+
const requested = raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
6880
|
+
const unknown$1 = requested.filter((r) => !allowedSet.has(r));
|
|
6881
|
+
if (unknown$1.length > 0) {
|
|
6882
|
+
const list = allowed.length > 0 ? allowed.join(", ") : "(none configured)";
|
|
6883
|
+
throw new Error(`unknown tenant shard role(s) "${unknown$1.join(", ")}"; must be a subset of tenant.d1Shards in the Tamer project config: ${list}`);
|
|
6884
|
+
}
|
|
6885
|
+
const seen = new Set(requested);
|
|
6886
|
+
return allowed.filter((r) => seen.has(r));
|
|
6887
|
+
}
|
|
6888
|
+
|
|
6889
|
+
//#endregion
|
|
6890
|
+
//#region src/core/state/StateConflictError.ts
|
|
6891
|
+
/** Thrown when D1 `cfi_state` was updated by another writer since {@link StateManager.hydrate}. */
|
|
6892
|
+
var StateConflictError = class extends Error {
|
|
6893
|
+
code = "STATE_CONFLICT";
|
|
6894
|
+
constructor(message) {
|
|
6895
|
+
super(message);
|
|
6896
|
+
this.name = "StateConflictError";
|
|
6897
|
+
}
|
|
6898
|
+
};
|
|
6899
|
+
|
|
6900
|
+
//#endregion
|
|
6901
|
+
//#region src/core/state/StateManager.ts
|
|
6902
|
+
/** D1 `tamer_kv.k` value for a given stack's state row. */
|
|
6903
|
+
function stateRowKey(stackName) {
|
|
6904
|
+
return `cfi_state:${stackName}`;
|
|
6905
|
+
}
|
|
6906
|
+
const OPERATION_HISTORY_CAP = 50;
|
|
6907
|
+
/**
|
|
6908
|
+
* Authoritative deployment state for an env.
|
|
6909
|
+
*
|
|
6910
|
+
* - **Non-local:** stored as JSON in Cloudflare D1 (`tamer-state-{env}`).
|
|
6911
|
+
* Call {@link hydrate} before {@link load}, then {@link persist} after mutations.
|
|
6912
|
+
* - **local:** in-memory only (no persistence).
|
|
6913
|
+
*/
|
|
6914
|
+
var StateManager = class {
|
|
6915
|
+
state = null;
|
|
6916
|
+
dirty = false;
|
|
6917
|
+
/** Set when {@link hydrate} loads remote state. */
|
|
6918
|
+
tamerStateDbUuid = null;
|
|
6919
|
+
/**
|
|
6920
|
+
* Remote `revision` at last hydrate (or last successful persist). Used for
|
|
6921
|
+
* optimistic concurrency on D1 persist.
|
|
6922
|
+
*/
|
|
6923
|
+
baselineRevision = 0;
|
|
6924
|
+
/**
|
|
6925
|
+
* @param tenantId `config.tenant.id` — recorded on the state row for
|
|
6926
|
+
* diagnostics; not part of the row key.
|
|
6927
|
+
* @param env Cloudflare environment name; selects the
|
|
6928
|
+
* `tamer-state-{env}` D1 database.
|
|
6929
|
+
* @param stackName Stack identity (`config.stack.name ?? tenant.slug`).
|
|
6930
|
+
* The state row in D1 is keyed `cfi_state:{stackName}`,
|
|
6931
|
+
* so multiple stacks coexist in one env D1 without
|
|
6932
|
+
* clobbering each other. Defaults to `"default"` —
|
|
6933
|
+
* unit tests that synthesize a StateManager without
|
|
6934
|
+
* a config get a stable key without extra plumbing.
|
|
6935
|
+
*/
|
|
6936
|
+
constructor(tenantId, env, stackName = DEFAULT_STACK_NAME) {
|
|
6937
|
+
this.tenantId = tenantId;
|
|
6938
|
+
this.env = env;
|
|
6939
|
+
this.stackName = stackName;
|
|
6940
|
+
}
|
|
6941
|
+
/**
|
|
6942
|
+
* Load state from D1 (remote) or allocate empty state (local).
|
|
6943
|
+
* Required before {@link load} for every command.
|
|
6944
|
+
*/
|
|
6945
|
+
async hydrate(api) {
|
|
6946
|
+
if (this.state) return;
|
|
6947
|
+
if (this.env === "local") {
|
|
6948
|
+
this.state = createEmptyCfiState(this.tenantId, this.env);
|
|
6949
|
+
this.baselineRevision = this.state.revision ?? 0;
|
|
6950
|
+
return;
|
|
6951
|
+
}
|
|
6952
|
+
const name = tamerStateDatabaseName(this.env);
|
|
6953
|
+
const uuid$1 = await findTamerStateDatabaseUuid(api, this.env);
|
|
6954
|
+
if (!uuid$1) throw new Error(`Tamer state database "${name}" not found. Run: tamer bootstrap --env ${this.env}`);
|
|
6955
|
+
this.tamerStateDbUuid = uuid$1;
|
|
6956
|
+
const rowKey = stateRowKey(this.stackName);
|
|
6957
|
+
const { rows } = await api.d1Query(uuid$1, `SELECT v FROM tamer_kv WHERE k = ?`, [rowKey]);
|
|
6958
|
+
if (rows.length === 0) {
|
|
6959
|
+
this.state = createEmptyCfiState(this.tenantId, this.env);
|
|
6960
|
+
this.baselineRevision = this.state.revision ?? 0;
|
|
6961
|
+
this.dirty = true;
|
|
6962
|
+
return;
|
|
6963
|
+
}
|
|
6964
|
+
const v = rows[0]["v"];
|
|
6965
|
+
if (typeof v !== "string") throw new Error(`tamer_kv.${rowKey} must be a string column`);
|
|
6966
|
+
this.state = parseCfiStateJson(v);
|
|
6967
|
+
this.baselineRevision = this.state.revision ?? 0;
|
|
6968
|
+
}
|
|
6969
|
+
/**
|
|
6970
|
+
* Stack identifier this manager is bound to (the `cfi_state:{name}` row
|
|
6971
|
+
* key suffix). Exposed so `fetchStackImports` and diagnostics can show
|
|
6972
|
+
* the operator which row this manager owns.
|
|
6973
|
+
*/
|
|
6974
|
+
getStackName() {
|
|
6975
|
+
return this.stackName;
|
|
6976
|
+
}
|
|
6977
|
+
/**
|
|
6978
|
+
* Allocate empty in-memory state without touching D1. Use for read-only
|
|
6979
|
+
* "what-would-state-look-like" snapshots (e.g. drift-aware plan refresh)
|
|
6980
|
+
* where we want to drive the module `sync` hooks against a fresh slate
|
|
6981
|
+
* and then discard the result. {@link persist} is unsafe afterwards
|
|
6982
|
+
* because there is no D1 baseline to compare against.
|
|
6983
|
+
*/
|
|
6984
|
+
hydrateInMemory() {
|
|
6985
|
+
if (this.state) return;
|
|
6986
|
+
this.state = createEmptyCfiState(this.tenantId, this.env);
|
|
6987
|
+
this.baselineRevision = this.state.revision ?? 0;
|
|
6988
|
+
}
|
|
6989
|
+
/** Clear cached state so the next {@link hydrate} reloads from D1. */
|
|
6990
|
+
reset() {
|
|
6991
|
+
this.state = null;
|
|
6992
|
+
this.tamerStateDbUuid = null;
|
|
6993
|
+
this.dirty = false;
|
|
6994
|
+
this.baselineRevision = 0;
|
|
6995
|
+
}
|
|
6996
|
+
load() {
|
|
6997
|
+
if (!this.state) throw new Error("StateManager: call await hydrate(api) before load()");
|
|
6998
|
+
return this.state;
|
|
6999
|
+
}
|
|
7000
|
+
get(derivedName) {
|
|
7001
|
+
return this.load().resources[derivedName];
|
|
7002
|
+
}
|
|
7003
|
+
set(derivedName, entry) {
|
|
7004
|
+
const s = this.load();
|
|
7005
|
+
s.resources[derivedName] = entry;
|
|
7006
|
+
s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7007
|
+
this.dirty = true;
|
|
7008
|
+
}
|
|
7009
|
+
delete(derivedName) {
|
|
7010
|
+
const s = this.load();
|
|
7011
|
+
delete s.resources[derivedName];
|
|
7012
|
+
s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7013
|
+
this.dirty = true;
|
|
7014
|
+
}
|
|
7015
|
+
getAll() {
|
|
7016
|
+
return this.load().resources;
|
|
7017
|
+
}
|
|
7018
|
+
getTenant(product, workspace) {
|
|
7019
|
+
return this.load().tenants?.[tenantStateKey(product, workspace)];
|
|
7020
|
+
}
|
|
7021
|
+
setTenant(entry) {
|
|
7022
|
+
const s = this.load();
|
|
7023
|
+
if (!s.tenants) s.tenants = {};
|
|
7024
|
+
s.tenants[tenantStateKey(entry.product, entry.workspace)] = entry;
|
|
7025
|
+
s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7026
|
+
this.dirty = true;
|
|
7027
|
+
}
|
|
7028
|
+
deleteTenant(product, workspace) {
|
|
7029
|
+
const s = this.load();
|
|
7030
|
+
if (!s.tenants) return;
|
|
7031
|
+
delete s.tenants[tenantStateKey(product, workspace)];
|
|
7032
|
+
s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7033
|
+
this.dirty = true;
|
|
7034
|
+
}
|
|
7035
|
+
listTenants() {
|
|
7036
|
+
return Object.values(this.load().tenants ?? {});
|
|
7037
|
+
}
|
|
7038
|
+
/** CloudFormation-style stack metadata (name, owner). Returns a copy. */
|
|
7039
|
+
getStackMeta() {
|
|
7040
|
+
const s = this.load().stack;
|
|
7041
|
+
return s ? { ...s } : void 0;
|
|
7042
|
+
}
|
|
7043
|
+
/**
|
|
7044
|
+
* Set or merge stack metadata. Pass `undefined` fields to clear them; only
|
|
7045
|
+
* provided keys are written, so callers can update one field at a time.
|
|
7046
|
+
*/
|
|
7047
|
+
setStackMeta(meta$2) {
|
|
7048
|
+
const s = this.load();
|
|
7049
|
+
s.stack = {
|
|
7050
|
+
...s.stack ?? {},
|
|
7051
|
+
...meta$2
|
|
7052
|
+
};
|
|
7053
|
+
s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7054
|
+
this.dirty = true;
|
|
7055
|
+
}
|
|
7056
|
+
/**
|
|
7057
|
+
* Resolved + persisted `outputs:` for this stack. Returns `{}` when none
|
|
7058
|
+
* have been recorded yet (e.g. before the first successful `apply`). The
|
|
7059
|
+
* returned object is a shallow copy — mutate via {@link replaceStackOutputs}.
|
|
7060
|
+
*/
|
|
7061
|
+
getStackOutputs() {
|
|
7062
|
+
return { ...this.load().stackOutputs ?? {} };
|
|
7063
|
+
}
|
|
7064
|
+
/**
|
|
7065
|
+
* Replace this stack's `stackOutputs` map wholesale. Pass `{}` to clear
|
|
7066
|
+
* (e.g. when `outputs` is removed from `tamer.config.ts`); pass a fresh
|
|
7067
|
+
* map keyed by output name to commit a successful apply's resolved values.
|
|
7068
|
+
* No-op when the new map is structurally identical to the existing one
|
|
7069
|
+
* (avoids gratuitous `revision` bumps on no-op applies).
|
|
7070
|
+
*/
|
|
7071
|
+
replaceStackOutputs(next) {
|
|
7072
|
+
const s = this.load();
|
|
7073
|
+
if (stackOutputsEqual(s.stackOutputs ?? {}, next)) return;
|
|
7074
|
+
if (Object.keys(next).length === 0) delete s.stackOutputs;
|
|
7075
|
+
else s.stackOutputs = { ...next };
|
|
7076
|
+
s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7077
|
+
this.dirty = true;
|
|
7078
|
+
}
|
|
7079
|
+
getLastOperation() {
|
|
7080
|
+
return this.load().lastOperation;
|
|
7081
|
+
}
|
|
7082
|
+
/** Completed operations (`succeeded` / `failed` only), newest first. */
|
|
7083
|
+
getOperationHistory() {
|
|
7084
|
+
const h = this.load().operationHistory;
|
|
7085
|
+
return h ? h.map((e) => ({ ...e })) : [];
|
|
7086
|
+
}
|
|
7087
|
+
/**
|
|
7088
|
+
* Begin recording a CloudFormation-style operation marker. Sets `status:
|
|
7089
|
+
* "in_progress"` and `startedAt`; pair with {@link finishOperation} on
|
|
7090
|
+
* success or {@link failOperation} on error. Persist between calls if the
|
|
7091
|
+
* operation may take a long time and you want concurrent operators to see
|
|
7092
|
+
* the in-progress marker.
|
|
7093
|
+
*/
|
|
7094
|
+
beginOperation(command$1, detail) {
|
|
7095
|
+
const s = this.load();
|
|
7096
|
+
s.lastOperation = {
|
|
7097
|
+
command: command$1,
|
|
7098
|
+
status: "in_progress",
|
|
7099
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7100
|
+
detail
|
|
7101
|
+
};
|
|
7102
|
+
s.syncedAt = s.lastOperation.startedAt;
|
|
7103
|
+
this.dirty = true;
|
|
7104
|
+
}
|
|
7105
|
+
finishOperation(detail) {
|
|
7106
|
+
const s = this.load();
|
|
7107
|
+
if (!s.lastOperation) return;
|
|
7108
|
+
s.lastOperation.status = "succeeded";
|
|
7109
|
+
s.lastOperation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7110
|
+
if (detail !== void 0) s.lastOperation.detail = detail;
|
|
7111
|
+
s.syncedAt = s.lastOperation.completedAt;
|
|
7112
|
+
this.appendTerminalOperationToHistory(s.lastOperation);
|
|
7113
|
+
this.dirty = true;
|
|
7114
|
+
}
|
|
7115
|
+
failOperation(errorMessage) {
|
|
7116
|
+
const s = this.load();
|
|
7117
|
+
if (!s.lastOperation) return;
|
|
7118
|
+
s.lastOperation.status = "failed";
|
|
7119
|
+
s.lastOperation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7120
|
+
s.lastOperation.errorMessage = errorMessage;
|
|
7121
|
+
s.syncedAt = s.lastOperation.completedAt;
|
|
7122
|
+
this.appendTerminalOperationToHistory(s.lastOperation);
|
|
7123
|
+
this.dirty = true;
|
|
7124
|
+
}
|
|
7125
|
+
appendTerminalOperationToHistory(op) {
|
|
7126
|
+
if (op.status !== "succeeded" && op.status !== "failed") return;
|
|
7127
|
+
const s = this.load();
|
|
7128
|
+
s.operationHistory = [{
|
|
7129
|
+
command: op.command,
|
|
7130
|
+
status: op.status,
|
|
7131
|
+
startedAt: op.startedAt,
|
|
7132
|
+
completedAt: op.completedAt,
|
|
7133
|
+
errorMessage: op.errorMessage,
|
|
7134
|
+
detail: op.detail
|
|
7135
|
+
}, ...s.operationHistory ?? []].slice(0, OPERATION_HISTORY_CAP);
|
|
7136
|
+
}
|
|
7137
|
+
/**
|
|
7138
|
+
* Persist to D1 (no-op for local). Uses optimistic concurrency: re-reads
|
|
7139
|
+
* `revision` before write; throws {@link StateConflictError} if another
|
|
7140
|
+
* writer advanced the row since {@link hydrate}.
|
|
7141
|
+
*/
|
|
7142
|
+
async persist(api) {
|
|
7143
|
+
if (this.env === "local") {
|
|
7144
|
+
this.dirty = false;
|
|
7145
|
+
return;
|
|
7146
|
+
}
|
|
7147
|
+
if (!this.dirty || !this.state || !this.tamerStateDbUuid) return;
|
|
7148
|
+
const rowKey = stateRowKey(this.stackName);
|
|
7149
|
+
const { rows } = await api.d1Query(this.tamerStateDbUuid, `SELECT v FROM tamer_kv WHERE k = ?`, [rowKey]);
|
|
7150
|
+
let remoteRev = 0;
|
|
7151
|
+
if (rows.length > 0) {
|
|
7152
|
+
const v = rows[0]["v"];
|
|
7153
|
+
if (typeof v === "string") remoteRev = parseCfiStateJson(v).revision ?? 0;
|
|
7154
|
+
}
|
|
7155
|
+
if (remoteRev !== this.baselineRevision) throw new StateConflictError(`Tamer state conflict (stack=${this.stackName}): remote revision ${remoteRev} !== expected ${this.baselineRevision}. Re-run after refresh.`);
|
|
7156
|
+
this.state.revision = remoteRev + 1;
|
|
7157
|
+
this.state.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7158
|
+
const json = JSON.stringify(this.state);
|
|
7159
|
+
await api.d1Query(this.tamerStateDbUuid, `INSERT INTO tamer_kv (k, v) VALUES (?, ?)
|
|
7160
|
+
ON CONFLICT(k) DO UPDATE SET v = excluded.v`, [rowKey, json]);
|
|
7161
|
+
this.baselineRevision = this.state.revision;
|
|
7162
|
+
this.dirty = false;
|
|
7163
|
+
}
|
|
7164
|
+
/** Mark clean without writing (e.g. before deleting the state database). */
|
|
7165
|
+
clearDirty() {
|
|
7166
|
+
this.dirty = false;
|
|
7167
|
+
}
|
|
7168
|
+
};
|
|
7169
|
+
function stackOutputsEqual(a, b) {
|
|
7170
|
+
const ak = Object.keys(a).sort();
|
|
7171
|
+
const bk = Object.keys(b).sort();
|
|
7172
|
+
if (ak.length !== bk.length) return false;
|
|
7173
|
+
for (let i = 0; i < ak.length; i++) {
|
|
7174
|
+
if (ak[i] !== bk[i]) return false;
|
|
7175
|
+
const k = ak[i];
|
|
7176
|
+
const av = a[k];
|
|
7177
|
+
const bv = b[k];
|
|
7178
|
+
if (av.value !== bv.value || av.source !== bv.source) return false;
|
|
7179
|
+
}
|
|
7180
|
+
return true;
|
|
7181
|
+
}
|
|
7182
|
+
|
|
7183
|
+
//#endregion
|
|
7184
|
+
//#region src/core/imports/fetchStackImports.ts
|
|
7185
|
+
const IMPORT_RE = /\$\{tamer:import:([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\}/g;
|
|
7186
|
+
/**
|
|
7187
|
+
* Walk the merged `CfiConfig` and collect every `${tamer:import:…}` ref
|
|
7188
|
+
* site along with where it was found. Used both to drive the pre-fetch
|
|
7189
|
+
* (which sibling stacks to load) and by `tamer status` to render an
|
|
7190
|
+
* "inbound imports" panel even before any `apply` has run.
|
|
7191
|
+
*
|
|
7192
|
+
* Self-imports (current stack importing from its own name) are filtered
|
|
7193
|
+
* out — they are almost always a config typo and would otherwise
|
|
7194
|
+
* silently resolve via the same row this command is about to write.
|
|
7195
|
+
*/
|
|
7196
|
+
function scanConfigForImports(config$1) {
|
|
7197
|
+
const selfStack = stackNameForConfig(config$1);
|
|
7198
|
+
const refs = [];
|
|
7199
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7200
|
+
const push = (raw, fieldPath) => {
|
|
7201
|
+
if (!raw) return;
|
|
7202
|
+
IMPORT_RE.lastIndex = 0;
|
|
7203
|
+
let m;
|
|
7204
|
+
while ((m = IMPORT_RE.exec(raw)) !== null) {
|
|
7205
|
+
const stack = m[1];
|
|
7206
|
+
const output = m[2];
|
|
7207
|
+
if (stack === selfStack) continue;
|
|
7208
|
+
const key = `${fieldPath}::${stack}.${output}`;
|
|
7209
|
+
if (seen.has(key)) continue;
|
|
7210
|
+
seen.add(key);
|
|
7211
|
+
refs.push({
|
|
7212
|
+
stack,
|
|
7213
|
+
output,
|
|
7214
|
+
fieldPath
|
|
7215
|
+
});
|
|
7216
|
+
}
|
|
7217
|
+
};
|
|
7218
|
+
const walkVars = (vars, pathPrefix) => {
|
|
7219
|
+
if (!vars) return;
|
|
7220
|
+
for (const [k, v] of Object.entries(vars)) push(materializeTamerResolvable(v), `${pathPrefix}.${k}`);
|
|
7221
|
+
};
|
|
7222
|
+
const walkR2BucketNames = (w, pathPrefix) => {
|
|
7223
|
+
if (!w.r2_buckets) return;
|
|
7224
|
+
w.r2_buckets.forEach((b, i) => {
|
|
7225
|
+
if (b.bucket_name === void 0) return;
|
|
7226
|
+
push(materializeTamerResolvable(b.bucket_name), `${pathPrefix}.r2_buckets[${i}].bucket_name`);
|
|
7227
|
+
});
|
|
7228
|
+
};
|
|
7229
|
+
const walkD1DatabaseNames = (w, pathPrefix) => {
|
|
7230
|
+
const d1 = w.resources?.d1;
|
|
7231
|
+
if (!d1) return;
|
|
7232
|
+
d1.forEach((d, i) => {
|
|
7233
|
+
if (d.databaseName === void 0) return;
|
|
7234
|
+
push(materializeTamerResolvable(d.databaseName), `${pathPrefix}.resources.d1[${i}].databaseName`);
|
|
7235
|
+
});
|
|
7236
|
+
};
|
|
7237
|
+
/** Service bindings / WfP namespace strings may carry `${tamer:import:…}`. */
|
|
7238
|
+
const walkBindingsWithRefs = (w, pathPrefix) => {
|
|
7239
|
+
w.services?.forEach((s, i) => {
|
|
7240
|
+
if (s.service === void 0) return;
|
|
7241
|
+
push(materializeTamerResolvable(s.service), `${pathPrefix}.services[${i}].service`);
|
|
7242
|
+
});
|
|
7243
|
+
w.dispatch_namespaces?.forEach((d, i) => {
|
|
7244
|
+
if (d.namespace === void 0) return;
|
|
7245
|
+
push(materializeTamerResolvable(d.namespace), `${pathPrefix}.dispatch_namespaces[${i}].namespace`);
|
|
7246
|
+
});
|
|
7247
|
+
};
|
|
7248
|
+
if (config$1.worker) {
|
|
7249
|
+
walkVars(config$1.worker.vars, "worker.vars");
|
|
7250
|
+
walkR2BucketNames(config$1.worker, "worker");
|
|
7251
|
+
walkD1DatabaseNames(config$1.worker, "worker");
|
|
7252
|
+
walkBindingsWithRefs(config$1.worker, "worker");
|
|
7253
|
+
if (config$1.worker.tamerRoutes) config$1.worker.tamerRoutes.forEach((r, i) => {
|
|
7254
|
+
push(r.host, `worker.tamerRoutes[${i}].host`);
|
|
7255
|
+
push(r.zone, `worker.tamerRoutes[${i}].zone`);
|
|
7256
|
+
});
|
|
7257
|
+
}
|
|
7258
|
+
if (config$1.workers) for (const [key, w] of Object.entries(config$1.workers)) {
|
|
7259
|
+
walkVars(w.vars, `worker[${key}].vars`);
|
|
7260
|
+
walkR2BucketNames(w, `worker[${key}]`);
|
|
7261
|
+
walkD1DatabaseNames(w, `worker[${key}]`);
|
|
7262
|
+
walkBindingsWithRefs(w, `worker[${key}]`);
|
|
7263
|
+
if (w.tamerRoutes) w.tamerRoutes.forEach((r, i) => {
|
|
7264
|
+
push(r.host, `worker[${key}].tamerRoutes[${i}].host`);
|
|
7265
|
+
push(r.zone, `worker[${key}].tamerRoutes[${i}].zone`);
|
|
7266
|
+
});
|
|
7267
|
+
}
|
|
7268
|
+
if (config$1.outputs) for (const [name, source] of Object.entries(config$1.outputs)) push(materializeTamerResolvable(source), `outputs.${name}`);
|
|
7269
|
+
return refs;
|
|
7270
|
+
}
|
|
7271
|
+
/** Distinct sibling stack names referenced anywhere in `config`. */
|
|
7272
|
+
function importedStackNames(config$1) {
|
|
7273
|
+
const refs = scanConfigForImports(config$1);
|
|
7274
|
+
return [...new Set(refs.map((r) => r.stack))].sort();
|
|
7275
|
+
}
|
|
7276
|
+
/**
|
|
7277
|
+
* Hydrate every imported sibling stack's persisted outputs and return
|
|
7278
|
+
* them shaped for {@link ReferenceContext.imports}.
|
|
7279
|
+
*
|
|
7280
|
+
* - `local` env: returns `{}` immediately. Local mode never persists
|
|
7281
|
+
* state, so cross-stack imports are inherently unresolvable. Callers
|
|
7282
|
+
* running in tolerant mode (`plan`, `status`) will see the placeholder
|
|
7283
|
+
* verbatim; strict callers (`apply`, `deploy`) will fail at resolution
|
|
7284
|
+
* with a clear "no imported stack" message.
|
|
7285
|
+
* - Missing sibling state row: recorded as an empty outputs map, so
|
|
7286
|
+
* `lookupImport` can produce a "no published outputs" diagnostic
|
|
7287
|
+
* (vs. the generic "stack not pre-fetched" error).
|
|
7288
|
+
*
|
|
7289
|
+
* The {@link CFApiClient} is shared with the caller for socket reuse.
|
|
7290
|
+
*/
|
|
7291
|
+
async function fetchStackImports(api, config$1, env) {
|
|
7292
|
+
const stacks = importedStackNames(config$1);
|
|
7293
|
+
if (stacks.length === 0 || env === "local") return {};
|
|
7294
|
+
const out = {};
|
|
7295
|
+
for (const stack of stacks) {
|
|
7296
|
+
const sibling = new StateManager(config$1.tenant.id, env, stack);
|
|
7297
|
+
try {
|
|
7298
|
+
await sibling.hydrate(api);
|
|
7299
|
+
} catch (err) {
|
|
7300
|
+
throw new Error(`Failed to hydrate imported stack "${stack}" from env "${env}": ${err instanceof Error ? err.message : String(err)}`);
|
|
7301
|
+
}
|
|
7302
|
+
const persisted = sibling.getStackOutputs();
|
|
7303
|
+
const flat = {};
|
|
7304
|
+
for (const [name, v] of Object.entries(persisted)) flat[name] = v.value;
|
|
7305
|
+
out[stack] = flat;
|
|
7306
|
+
}
|
|
7307
|
+
return out;
|
|
7308
|
+
}
|
|
7309
|
+
|
|
7310
|
+
//#endregion
|
|
7311
|
+
//#region src/core/secrets/declared.ts
|
|
7312
|
+
/** Declared secret names for a worker; empty when `secrets` is absent. */
|
|
7313
|
+
function requiredSecretsForWorker(workerConfig) {
|
|
7314
|
+
return workerConfig.secrets?.required ?? [];
|
|
7315
|
+
}
|
|
7316
|
+
|
|
7317
|
+
//#endregion
|
|
7318
|
+
//#region src/core/secrets/SecretsVault.ts
|
|
7319
|
+
/**
|
|
7320
|
+
* Encrypted secrets vault backed by `tamer-secrets-{env}` D1.
|
|
7321
|
+
* Stores ciphertext only — never plaintext.
|
|
7322
|
+
*/
|
|
7323
|
+
var SecretsVault = class {
|
|
7324
|
+
databaseId;
|
|
7325
|
+
constructor(api, env, databaseId) {
|
|
7326
|
+
this.api = api;
|
|
7327
|
+
this.env = env;
|
|
7328
|
+
this.databaseId = databaseId;
|
|
7329
|
+
}
|
|
7330
|
+
async dbId() {
|
|
7331
|
+
if (this.databaseId) return this.databaseId;
|
|
7332
|
+
const uuid$1 = await findTamerSecretsDatabaseUuid(this.api, this.env);
|
|
7333
|
+
if (!uuid$1) throw new Error(`secrets vault not provisioned for env "${this.env}" (expected D1 ${tamerSecretsDatabaseName(this.env)}); run tamer bootstrap`);
|
|
7334
|
+
this.databaseId = uuid$1;
|
|
7335
|
+
return uuid$1;
|
|
7336
|
+
}
|
|
7337
|
+
async upsert(name, encrypted, valueHash, options) {
|
|
7338
|
+
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7339
|
+
const updatedBy = options?.updatedBy;
|
|
7340
|
+
const db = await this.dbId();
|
|
7341
|
+
await this.api.d1Query(db, `INSERT INTO secrets (
|
|
7342
|
+
name, ciphertext, iv, wrapped_dek, dek_iv, value_hash, updated_at, updated_by
|
|
7343
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
7344
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
7345
|
+
ciphertext = excluded.ciphertext,
|
|
7346
|
+
iv = excluded.iv,
|
|
7347
|
+
wrapped_dek = excluded.wrapped_dek,
|
|
7348
|
+
dek_iv = excluded.dek_iv,
|
|
7349
|
+
value_hash = excluded.value_hash,
|
|
7350
|
+
updated_at = excluded.updated_at,
|
|
7351
|
+
updated_by = excluded.updated_by`, [
|
|
7352
|
+
name,
|
|
7353
|
+
blobParam(encrypted.ciphertext),
|
|
7354
|
+
blobParam(encrypted.iv),
|
|
7355
|
+
blobParam(encrypted.wrappedDek),
|
|
7356
|
+
blobParam(encrypted.dekIv),
|
|
7357
|
+
valueHash,
|
|
7358
|
+
updatedAt,
|
|
7359
|
+
updatedBy ?? null
|
|
7360
|
+
]);
|
|
7361
|
+
await this.api.d1Query(db, `INSERT INTO secret_history (name, value_hash, updated_at, updated_by)
|
|
7362
|
+
VALUES (?, ?, ?, ?)`, [
|
|
7363
|
+
name,
|
|
7364
|
+
valueHash,
|
|
7365
|
+
updatedAt,
|
|
7366
|
+
updatedBy ?? null
|
|
7367
|
+
]);
|
|
7368
|
+
await this.api.d1Query(db, `DELETE FROM secret_history
|
|
7369
|
+
WHERE name = ?
|
|
7370
|
+
AND rowid IN (
|
|
7371
|
+
SELECT rowid FROM (
|
|
7372
|
+
SELECT rowid FROM secret_history
|
|
7373
|
+
WHERE name = ?
|
|
7374
|
+
ORDER BY updated_at DESC
|
|
7375
|
+
LIMIT -1 OFFSET ?
|
|
7376
|
+
)
|
|
7377
|
+
)`, [
|
|
7378
|
+
name,
|
|
7379
|
+
name,
|
|
7380
|
+
SECRET_HISTORY_CAP
|
|
7381
|
+
]);
|
|
7382
|
+
}
|
|
7383
|
+
async get(name) {
|
|
7384
|
+
const db = await this.dbId();
|
|
7385
|
+
const { rows } = await this.api.d1Query(db, `SELECT name, ciphertext, iv, wrapped_dek, dek_iv, value_hash, updated_at, updated_by
|
|
7386
|
+
FROM secrets WHERE name = ?`, [name]);
|
|
7387
|
+
if (rows.length === 0) return void 0;
|
|
7388
|
+
return rowToVaultSecret(rows[0]);
|
|
7389
|
+
}
|
|
7390
|
+
async list() {
|
|
7391
|
+
const db = await this.dbId();
|
|
7392
|
+
const { rows } = await this.api.d1Query(db, `SELECT name, value_hash, updated_at, updated_by
|
|
7393
|
+
FROM secrets ORDER BY name`);
|
|
7394
|
+
return rows.map((row) => ({
|
|
7395
|
+
name: String(row.name),
|
|
7396
|
+
valueHash: row.value_hash,
|
|
7397
|
+
updatedAt: String(row.updated_at),
|
|
7398
|
+
updatedBy: row.updated_by != null ? String(row.updated_by) : void 0
|
|
7399
|
+
}));
|
|
7400
|
+
}
|
|
7401
|
+
async delete(name) {
|
|
7402
|
+
const db = await this.dbId();
|
|
7403
|
+
const { rows } = await this.api.d1Query(db, `DELETE FROM secrets WHERE name = ? RETURNING name`, [name]);
|
|
7404
|
+
return rows.length > 0;
|
|
7405
|
+
}
|
|
7406
|
+
/** Append an audit row (e.g. after `secrets get`) without changing the secret. */
|
|
7407
|
+
async appendAudit(name, options) {
|
|
7408
|
+
const record$1 = await this.get(name);
|
|
7409
|
+
if (!record$1) throw new Error(`secret "${name}" not found in vault`);
|
|
7410
|
+
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7411
|
+
const updatedBy = options?.updatedBy;
|
|
7412
|
+
const db = await this.dbId();
|
|
7413
|
+
await this.api.d1Query(db, `INSERT INTO secret_history (name, value_hash, updated_at, updated_by)
|
|
7414
|
+
VALUES (?, ?, ?, ?)`, [
|
|
7415
|
+
name,
|
|
7416
|
+
record$1.valueHash,
|
|
7417
|
+
updatedAt,
|
|
7418
|
+
updatedBy ?? null
|
|
7419
|
+
]);
|
|
7420
|
+
await this.api.d1Query(db, `DELETE FROM secret_history
|
|
7421
|
+
WHERE name = ?
|
|
7422
|
+
AND rowid IN (
|
|
7423
|
+
SELECT rowid FROM (
|
|
7424
|
+
SELECT rowid FROM secret_history
|
|
7425
|
+
WHERE name = ?
|
|
7426
|
+
ORDER BY updated_at DESC
|
|
7427
|
+
LIMIT -1 OFFSET ?
|
|
7428
|
+
)
|
|
7429
|
+
)`, [
|
|
7430
|
+
name,
|
|
7431
|
+
name,
|
|
7432
|
+
SECRET_HISTORY_CAP
|
|
7433
|
+
]);
|
|
7434
|
+
}
|
|
7435
|
+
async history(name) {
|
|
7436
|
+
const db = await this.dbId();
|
|
7437
|
+
const { rows } = await this.api.d1Query(db, `SELECT name, value_hash, updated_at, updated_by
|
|
7438
|
+
FROM secret_history
|
|
7439
|
+
WHERE name = ?
|
|
7440
|
+
ORDER BY updated_at DESC`, [name]);
|
|
7441
|
+
return rows.map((row) => ({
|
|
7442
|
+
name: String(row.name),
|
|
7443
|
+
valueHash: row.value_hash,
|
|
7444
|
+
updatedAt: String(row.updated_at),
|
|
7445
|
+
updatedBy: row.updated_by != null ? String(row.updated_by) : void 0
|
|
7446
|
+
}));
|
|
7447
|
+
}
|
|
7448
|
+
};
|
|
7449
|
+
function rowToVaultSecret(row) {
|
|
7450
|
+
return {
|
|
7451
|
+
name: String(row.name),
|
|
7452
|
+
ciphertext: blobFromRow(row.ciphertext),
|
|
7453
|
+
iv: blobFromRow(row.iv),
|
|
7454
|
+
wrappedDek: blobFromRow(row.wrapped_dek),
|
|
7455
|
+
dekIv: blobFromRow(row.dek_iv),
|
|
7456
|
+
valueHash: row.value_hash,
|
|
7457
|
+
updatedAt: String(row.updated_at),
|
|
7458
|
+
updatedBy: row.updated_by != null ? String(row.updated_by) : void 0
|
|
7459
|
+
};
|
|
7460
|
+
}
|
|
7461
|
+
/** D1 HTTP API accepts base64 for BLOB bind parameters. */
|
|
7462
|
+
function blobParam(bytes) {
|
|
7463
|
+
return Buffer.from(bytes).toString("base64");
|
|
7464
|
+
}
|
|
7465
|
+
function blobFromRow(value) {
|
|
7466
|
+
if (value instanceof Uint8Array) return value;
|
|
7467
|
+
if (typeof value === "string") return new Uint8Array(Buffer.from(value, "base64"));
|
|
7468
|
+
if (Array.isArray(value)) return new Uint8Array(value);
|
|
7469
|
+
throw new Error("secrets vault: invalid BLOB column value");
|
|
7470
|
+
}
|
|
7471
|
+
|
|
7472
|
+
//#endregion
|
|
7473
|
+
//#region src/cli/commands/secrets/context.ts
|
|
7474
|
+
function resolveSecretsEnv(env) {
|
|
7475
|
+
const resolved = env ?? "dev";
|
|
7476
|
+
if (resolved === "local") throw new Error("secrets commands require a remote env (e.g. --env dev); \"local\" has no vault");
|
|
7477
|
+
return resolved;
|
|
7478
|
+
}
|
|
7479
|
+
/** Operator identity for vault audit rows (`USER` / `USERNAME` / `cli`). */
|
|
7480
|
+
function cliUpdatedBy() {
|
|
7481
|
+
return process.env.USER ?? process.env.USERNAME ?? "cli";
|
|
7482
|
+
}
|
|
7483
|
+
function vaultReaderFromVault(vault) {
|
|
7484
|
+
return { async get(name) {
|
|
7485
|
+
const row = await vault.get(name);
|
|
7486
|
+
if (!row) return void 0;
|
|
7487
|
+
return {
|
|
7488
|
+
name: row.name,
|
|
7489
|
+
valueHash: row.valueHash
|
|
7490
|
+
};
|
|
7491
|
+
} };
|
|
7492
|
+
}
|
|
7493
|
+
async function createSecretsContext(options) {
|
|
7494
|
+
const env = resolveSecretsEnv(options.env);
|
|
7495
|
+
const config$1 = await loadConfig(options.configPath, { env });
|
|
7496
|
+
const accountId = config$1.account_id ?? cloudflareAccountIdFromEnv();
|
|
7497
|
+
if (!accountId) throw new Error("account_id required in config or CLOUDFLARE_ACCOUNT_ID env var");
|
|
7498
|
+
const api = new CFApiClient(accountId);
|
|
7499
|
+
const vault = new SecretsVault(api, env, await ensureTamerSecretsDatabase(api, env));
|
|
7500
|
+
const state = new StateManager(config$1.tenant.id, env, stackNameForConfig(config$1));
|
|
7501
|
+
await state.hydrate(api);
|
|
7502
|
+
const masterKey = readMasterKeyFromEnv(env);
|
|
7503
|
+
return {
|
|
7504
|
+
env,
|
|
7505
|
+
config: config$1,
|
|
7506
|
+
api,
|
|
7507
|
+
vault,
|
|
7508
|
+
state,
|
|
7509
|
+
naming: namingFromConfig(config$1),
|
|
7510
|
+
accountId,
|
|
7511
|
+
masterKey
|
|
7512
|
+
};
|
|
7513
|
+
}
|
|
7514
|
+
async function collectSecretWorkers(ctx, workerFilter) {
|
|
7515
|
+
const workers = await getWorkers(ctx.config);
|
|
7516
|
+
const imports = await fetchStackImports(ctx.api, ctx.config, ctx.env).catch(() => ({}));
|
|
7517
|
+
const out = [];
|
|
7518
|
+
for (const [workerKey, workerConfig] of workers) {
|
|
7519
|
+
if (workerFilter && workerKey !== workerFilter) continue;
|
|
7520
|
+
const required$1 = requiredSecretsForWorker(mergeWorkerConfigForResourcePick(ctx.config, workerKey, workerConfig, ctx.env, ctx.accountId, ctx.naming, ctx.state, {
|
|
7521
|
+
referencesMode: "tolerant",
|
|
7522
|
+
imports
|
|
7523
|
+
}));
|
|
7524
|
+
if (required$1.length === 0) continue;
|
|
7525
|
+
const deployedName = resolveDeployedWorkerName(ctx.config, workerKey, workerConfig, ctx.env, ctx.naming);
|
|
7526
|
+
let workerSecretNames = [];
|
|
7527
|
+
try {
|
|
7528
|
+
workerSecretNames = await ctx.api.workersSecretsList(deployedName);
|
|
7529
|
+
} catch {
|
|
7530
|
+
workerSecretNames = [];
|
|
7531
|
+
}
|
|
7532
|
+
out.push({
|
|
7533
|
+
workerKey,
|
|
7534
|
+
required: required$1,
|
|
7535
|
+
workerSecretNames,
|
|
7536
|
+
deployedName
|
|
7537
|
+
});
|
|
7538
|
+
}
|
|
7539
|
+
return out;
|
|
7540
|
+
}
|
|
7541
|
+
function assertSecretName(name) {
|
|
7542
|
+
const trimmed = name?.trim();
|
|
7543
|
+
if (!trimmed) throw new Error("secret name is required");
|
|
7544
|
+
return trimmed;
|
|
7545
|
+
}
|
|
7546
|
+
/** Vault + master key for deploy auto-push (remote envs only). */
|
|
7547
|
+
async function createDeploySecretsResources(api, env) {
|
|
7548
|
+
const resolvedEnv = resolveSecretsEnv(env);
|
|
7549
|
+
return {
|
|
7550
|
+
vault: new SecretsVault(api, resolvedEnv, await ensureTamerSecretsDatabase(api, resolvedEnv)),
|
|
7551
|
+
masterKey: readMasterKeyFromEnv(resolvedEnv)
|
|
7552
|
+
};
|
|
7553
|
+
}
|
|
7554
|
+
|
|
7555
|
+
//#endregion
|
|
7556
|
+
//#region src/cli/commands/secrets/init.ts
|
|
7557
|
+
async function runSecretsInit(options) {
|
|
7558
|
+
const env = resolveSecretsEnv(options.env);
|
|
7559
|
+
const accountId = (await loadConfig(options.configPath, { env })).account_id ?? cloudflareAccountIdFromEnv();
|
|
7560
|
+
if (!accountId) throw new Error("account_id required in config or CLOUDFLARE_ACCOUNT_ID env var");
|
|
7561
|
+
const masterKey = generateMasterKey();
|
|
7562
|
+
const varName = masterKeyEnvVarName(env);
|
|
7563
|
+
const uuid$1 = await ensureTamerSecretsDatabase(new CFApiClient(accountId), env);
|
|
7564
|
+
console.log(`Secrets vault ready: D1 uuid=${uuid$1} name=${tamerSecretsDatabaseName(env)}`);
|
|
7565
|
+
console.log("");
|
|
7566
|
+
console.log(`Generated master key for env "${env}" (store in two durable places — CI + password manager):`);
|
|
7567
|
+
console.log("");
|
|
7568
|
+
console.log(`${varName}=${masterKey}`);
|
|
7569
|
+
console.log("");
|
|
7570
|
+
console.log("This key is shown once. Tamer never persists it.");
|
|
7571
|
+
}
|
|
7572
|
+
|
|
7573
|
+
//#endregion
|
|
7574
|
+
//#region src/core/secrets/crypto.ts
|
|
7575
|
+
const AES_GCM = "AES-GCM";
|
|
7576
|
+
const AES_KEY_LENGTH = 256;
|
|
7577
|
+
const IV_BYTE_LENGTH = 12;
|
|
7578
|
+
function toArrayBuffer(bytes) {
|
|
7579
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
7580
|
+
}
|
|
7581
|
+
/** Thrown when decrypt or re-wrap fails (wrong key, tampered ciphertext, etc.). */
|
|
7582
|
+
var SecretsCryptoError = class extends Error {
|
|
7583
|
+
code = "SECRETS_CRYPTO_ERROR";
|
|
7584
|
+
constructor(message) {
|
|
7585
|
+
super(message);
|
|
7586
|
+
this.name = "SecretsCryptoError";
|
|
7587
|
+
}
|
|
7588
|
+
};
|
|
7589
|
+
/** Envelope-encrypt a secret value under the master key. */
|
|
7590
|
+
async function encryptSecretValue(plaintext, masterKey) {
|
|
7591
|
+
const kek = await importAesKey(masterKey, ["wrapKey"]);
|
|
7592
|
+
const dek = await crypto.subtle.generateKey({
|
|
7593
|
+
name: AES_GCM,
|
|
7594
|
+
length: AES_KEY_LENGTH
|
|
7595
|
+
}, true, ["encrypt", "decrypt"]);
|
|
7596
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTE_LENGTH));
|
|
7597
|
+
const dekIv = crypto.getRandomValues(new Uint8Array(IV_BYTE_LENGTH));
|
|
7598
|
+
const plaintextBytes = new TextEncoder().encode(plaintext);
|
|
7599
|
+
const ciphertextBuffer = await crypto.subtle.encrypt({
|
|
7600
|
+
name: AES_GCM,
|
|
7601
|
+
iv
|
|
7602
|
+
}, dek, plaintextBytes);
|
|
7603
|
+
const wrappedDekBuffer = await crypto.subtle.wrapKey("raw", dek, kek, {
|
|
7604
|
+
name: AES_GCM,
|
|
7605
|
+
iv: dekIv
|
|
7606
|
+
});
|
|
7607
|
+
return {
|
|
7608
|
+
ciphertext: new Uint8Array(ciphertextBuffer),
|
|
7609
|
+
iv,
|
|
7610
|
+
wrappedDek: new Uint8Array(wrappedDekBuffer),
|
|
7611
|
+
dekIv
|
|
7612
|
+
};
|
|
7613
|
+
}
|
|
7614
|
+
/** Decrypt an envelope-encrypted secret value. */
|
|
7615
|
+
async function decryptSecretValue(encrypted, masterKey) {
|
|
7616
|
+
try {
|
|
7617
|
+
const kek = await importAesKey(masterKey, ["unwrapKey"]);
|
|
7618
|
+
const dek = await crypto.subtle.unwrapKey("raw", toArrayBuffer(encrypted.wrappedDek), kek, {
|
|
7619
|
+
name: AES_GCM,
|
|
7620
|
+
iv: toArrayBuffer(encrypted.dekIv)
|
|
7621
|
+
}, {
|
|
7622
|
+
name: AES_GCM,
|
|
7623
|
+
length: AES_KEY_LENGTH
|
|
7624
|
+
}, false, ["decrypt"]);
|
|
7625
|
+
const plaintextBuffer = await crypto.subtle.decrypt({
|
|
7626
|
+
name: AES_GCM,
|
|
7627
|
+
iv: toArrayBuffer(encrypted.iv)
|
|
7628
|
+
}, dek, toArrayBuffer(encrypted.ciphertext));
|
|
7629
|
+
return new TextDecoder().decode(plaintextBuffer);
|
|
7630
|
+
} catch {
|
|
7631
|
+
throw new SecretsCryptoError("Failed to decrypt secret (wrong master key or corrupted ciphertext)");
|
|
7632
|
+
}
|
|
7633
|
+
}
|
|
7634
|
+
async function importAesKey(rawKey, usages) {
|
|
7635
|
+
return crypto.subtle.importKey("raw", toArrayBuffer(rawKey), {
|
|
7636
|
+
name: AES_GCM,
|
|
7637
|
+
length: AES_KEY_LENGTH
|
|
7638
|
+
}, false, usages);
|
|
7639
|
+
}
|
|
7640
|
+
|
|
7641
|
+
//#endregion
|
|
7642
|
+
//#region src/core/secrets/fingerprint.ts
|
|
7643
|
+
/** SHA-256 fingerprint of secret plaintext (non-reversible). */
|
|
7644
|
+
async function secretValueFingerprint(plaintext) {
|
|
7645
|
+
const bytes = new TextEncoder().encode(plaintext);
|
|
7646
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
|
|
7647
|
+
return `sha256:${bytesToHex(new Uint8Array(hashBuffer))}`;
|
|
7648
|
+
}
|
|
7649
|
+
function bytesToHex(bytes) {
|
|
7650
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
7651
|
+
}
|
|
7652
|
+
|
|
7653
|
+
//#endregion
|
|
7654
|
+
//#region src/cli/commands/secrets/stdin.ts
|
|
7655
|
+
/**
|
|
7656
|
+
* Read a secret value from stdin (pipe only — avoids shell history / argv).
|
|
7657
|
+
* Interactive prompt is intentionally not supported in v1.
|
|
7658
|
+
*/
|
|
7659
|
+
async function readSecretValueFromStdin() {
|
|
7660
|
+
if (process.stdin.isTTY) throw new Error("secrets set: pipe the value on stdin (e.g. echo -n 'value' | tamer secrets set NAME --env dev); interactive entry is not supported yet");
|
|
7661
|
+
const chunks = [];
|
|
7662
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
7663
|
+
let value = Buffer.concat(chunks).toString("utf8");
|
|
7664
|
+
if (value.endsWith("\n")) value = value.slice(0, -1);
|
|
7665
|
+
if (value.endsWith("\r")) value = value.slice(0, -1);
|
|
7666
|
+
if (!value) throw new Error("secrets set: empty value on stdin");
|
|
7667
|
+
return value;
|
|
7668
|
+
}
|
|
7669
|
+
|
|
7670
|
+
//#endregion
|
|
7671
|
+
//#region src/cli/commands/secrets/set.ts
|
|
7672
|
+
async function runSecretsSet(options) {
|
|
7673
|
+
const name = assertSecretName(options.name);
|
|
7674
|
+
const ctx = await createSecretsContext({
|
|
7675
|
+
env: options.env,
|
|
7676
|
+
configPath: options.configPath
|
|
7677
|
+
});
|
|
7678
|
+
const plaintext = options.readValue ? await options.readValue() : await readSecretValueFromStdin();
|
|
7679
|
+
const encrypted = await encryptSecretValue(plaintext, ctx.masterKey);
|
|
7680
|
+
const valueHash = await secretValueFingerprint(plaintext);
|
|
7681
|
+
await ctx.vault.upsert(name, encrypted, valueHash, { updatedBy: cliUpdatedBy() });
|
|
7682
|
+
console.log(`secrets: stored ${name} (${valueHash}) in ${ctx.env} vault`);
|
|
7683
|
+
}
|
|
7684
|
+
|
|
7685
|
+
//#endregion
|
|
7686
|
+
//#region src/cli/commands/secrets/dotenv.ts
|
|
7687
|
+
/** Parse a dotenv-style file into key/value pairs (no variable expansion). */
|
|
7688
|
+
function parseDotenvContent(content) {
|
|
7689
|
+
const result = {};
|
|
7690
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
7691
|
+
const line = rawLine.trim();
|
|
7692
|
+
if (!line || line.startsWith("#")) continue;
|
|
7693
|
+
const eq = line.indexOf("=");
|
|
7694
|
+
if (eq <= 0) continue;
|
|
7695
|
+
const key = line.slice(0, eq).trim();
|
|
7696
|
+
let value = line.slice(eq + 1).trim();
|
|
7697
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
7698
|
+
if (key) result[key] = value;
|
|
7699
|
+
}
|
|
7700
|
+
return result;
|
|
7701
|
+
}
|
|
7702
|
+
function readDotenvFile(filePath) {
|
|
7703
|
+
return parseDotenvContent(readFileSync(resolve(process.cwd(), filePath), "utf8"));
|
|
7704
|
+
}
|
|
7705
|
+
/**
|
|
7706
|
+
* Merge env vars for bulk load: all keys from `process.env`, then file
|
|
7707
|
+
* entries overwrite on duplicate keys (file wins).
|
|
7708
|
+
*/
|
|
7709
|
+
function mergeLoadSources(fileEntries) {
|
|
7710
|
+
const merged = {};
|
|
7711
|
+
for (const [key, value] of Object.entries(process.env)) if (value != null && value !== "") merged[key] = value;
|
|
7712
|
+
for (const [key, value] of Object.entries(fileEntries)) merged[key] = value;
|
|
7713
|
+
return merged;
|
|
7714
|
+
}
|
|
7715
|
+
|
|
7716
|
+
//#endregion
|
|
7717
|
+
//#region src/cli/commands/secrets/load.ts
|
|
7718
|
+
async function runSecretsLoad(options) {
|
|
7719
|
+
if (!options.file?.trim()) throw new Error("usage: tamer secrets load --file <path> --env <env>");
|
|
7720
|
+
const ctx = await createSecretsContext({
|
|
7721
|
+
env: options.env,
|
|
7722
|
+
configPath: options.configPath
|
|
7723
|
+
});
|
|
7724
|
+
const merged = mergeLoadSources(readDotenvFile(options.file));
|
|
7725
|
+
const names = Object.keys(merged).sort();
|
|
7726
|
+
if (names.length === 0) {
|
|
7727
|
+
console.log("secrets load: no entries to import");
|
|
7728
|
+
return;
|
|
7729
|
+
}
|
|
7730
|
+
let count = 0;
|
|
7731
|
+
for (const name of names) {
|
|
7732
|
+
const plaintext = merged[name];
|
|
7733
|
+
const encrypted = await encryptSecretValue(plaintext, ctx.masterKey);
|
|
7734
|
+
const valueHash = await secretValueFingerprint(plaintext);
|
|
7735
|
+
await ctx.vault.upsert(name, encrypted, valueHash, { updatedBy: cliUpdatedBy() });
|
|
7736
|
+
count += 1;
|
|
7737
|
+
}
|
|
7738
|
+
console.log(`secrets: loaded ${count} secret(s) into ${ctx.env} vault from ${options.file}`);
|
|
7739
|
+
}
|
|
7740
|
+
|
|
7741
|
+
//#endregion
|
|
7742
|
+
//#region src/cli/commands/secrets/get.ts
|
|
7743
|
+
async function defaultConfirm(prompt) {
|
|
7744
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("secrets get: confirmation required; run in an interactive terminal or use a test hook");
|
|
7745
|
+
const rl = readline.createInterface({
|
|
7746
|
+
input: process.stdin,
|
|
7747
|
+
output: process.stdout
|
|
7748
|
+
});
|
|
7749
|
+
try {
|
|
7750
|
+
const normalized = (await rl.question(`${prompt} [y/N] `)).trim().toLowerCase();
|
|
7751
|
+
return normalized === "y" || normalized === "yes";
|
|
7752
|
+
} finally {
|
|
7753
|
+
rl.close();
|
|
7754
|
+
}
|
|
7755
|
+
}
|
|
7756
|
+
async function runSecretsGet(options) {
|
|
7757
|
+
const name = assertSecretName(options.name);
|
|
7758
|
+
const ctx = await createSecretsContext({
|
|
7759
|
+
env: options.env,
|
|
7760
|
+
configPath: options.configPath
|
|
7761
|
+
});
|
|
7762
|
+
const record$1 = await ctx.vault.get(name);
|
|
7763
|
+
if (!record$1) throw new Error(`secret "${name}" not found in vault`);
|
|
7764
|
+
const confirm = options.confirm ?? defaultConfirm;
|
|
7765
|
+
if (!options.yes) {
|
|
7766
|
+
if (!await confirm(`Reveal secret "${name}" from ${ctx.env} vault? This action is audit-logged.`)) {
|
|
7767
|
+
console.log("secrets get: cancelled");
|
|
7768
|
+
return;
|
|
7769
|
+
}
|
|
7770
|
+
}
|
|
7771
|
+
await ctx.vault.appendAudit(name, { updatedBy: cliUpdatedBy() });
|
|
7772
|
+
const plaintext = await decryptSecretValue({
|
|
7773
|
+
ciphertext: record$1.ciphertext,
|
|
7774
|
+
iv: record$1.iv,
|
|
7775
|
+
wrappedDek: record$1.wrappedDek,
|
|
7776
|
+
dekIv: record$1.dekIv
|
|
7777
|
+
}, ctx.masterKey);
|
|
7778
|
+
process.stdout.write(plaintext);
|
|
7779
|
+
if (!plaintext.endsWith("\n")) process.stdout.write("\n");
|
|
7780
|
+
}
|
|
7781
|
+
|
|
7782
|
+
//#endregion
|
|
7783
|
+
//#region src/cli/commands/secrets/list.ts
|
|
7784
|
+
async function runSecretsList(options) {
|
|
7785
|
+
const ctx = await createSecretsContext({
|
|
7786
|
+
env: options.env,
|
|
7787
|
+
configPath: options.configPath
|
|
7788
|
+
});
|
|
7789
|
+
const items = await ctx.vault.list();
|
|
7790
|
+
if (items.length === 0) {
|
|
7791
|
+
console.log(`secrets (${ctx.env}): (empty)`);
|
|
7792
|
+
return;
|
|
7793
|
+
}
|
|
7794
|
+
console.log(`secrets (${ctx.env}):`);
|
|
7795
|
+
for (const item of items) {
|
|
7796
|
+
const by = item.updatedBy ? ` by ${item.updatedBy}` : "";
|
|
7797
|
+
console.log(` ${item.name} ${item.valueHash} last-set ${item.updatedAt}${by}`);
|
|
7798
|
+
}
|
|
7799
|
+
}
|
|
7800
|
+
|
|
7801
|
+
//#endregion
|
|
7802
|
+
//#region src/cli/commands/secrets/rm.ts
|
|
7803
|
+
async function runSecretsRm(options) {
|
|
7804
|
+
const name = assertSecretName(options.name);
|
|
7805
|
+
const ctx = await createSecretsContext({
|
|
7806
|
+
env: options.env,
|
|
7807
|
+
configPath: options.configPath
|
|
7808
|
+
});
|
|
7809
|
+
if (!await ctx.vault.delete(name)) throw new Error(`secret "${name}" not found in vault`);
|
|
7810
|
+
console.log(`secrets: removed ${name} from ${ctx.env} vault`);
|
|
7811
|
+
}
|
|
7812
|
+
|
|
7813
|
+
//#endregion
|
|
7814
|
+
//#region src/core/secrets/reconcile.ts
|
|
7815
|
+
/** State row key: `secret:{worker}:{name}`. */
|
|
7816
|
+
function secretStateKey(worker, name) {
|
|
7817
|
+
return `secret:${worker}:${name}`;
|
|
7818
|
+
}
|
|
7819
|
+
/** Plan/drift derived identifier: `{worker}:{name}`. */
|
|
7820
|
+
function secretDerivedName(worker, name) {
|
|
7821
|
+
return `${worker}:${name}`;
|
|
7822
|
+
}
|
|
7823
|
+
/**
|
|
7824
|
+
* Compare declared secrets × vault fingerprints × state last-pushed hashes ×
|
|
7825
|
+
* worker presence. Returns one entry per relevant secret×worker pair.
|
|
7826
|
+
*/
|
|
7827
|
+
async function reconcileSecrets(input) {
|
|
7828
|
+
const { workers, vault, state } = input;
|
|
7829
|
+
const allState = state.getAll();
|
|
7830
|
+
const secretStateByKey = /* @__PURE__ */ new Map();
|
|
7831
|
+
for (const [key, entry] of Object.entries(allState)) if (entry.type === "secret") secretStateByKey.set(key, entry);
|
|
7832
|
+
const entries = [];
|
|
7833
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7834
|
+
function push(entry) {
|
|
7835
|
+
const key = secretStateKey(entry.worker, entry.name);
|
|
7836
|
+
if (seen.has(key)) return;
|
|
7837
|
+
seen.add(key);
|
|
7838
|
+
entries.push(entry);
|
|
7839
|
+
}
|
|
7840
|
+
for (const { workerKey, required: required$1, workerSecretNames } of workers) {
|
|
7841
|
+
const onWorkerSet = new Set(workerSecretNames);
|
|
7842
|
+
for (const name of required$1) {
|
|
7843
|
+
const vaultEntry = await vault.get(name);
|
|
7844
|
+
const stateEntry = secretStateByKey.get(secretStateKey(workerKey, name));
|
|
7845
|
+
push(classifyDeclaredSecret({
|
|
7846
|
+
worker: workerKey,
|
|
7847
|
+
name,
|
|
7848
|
+
vaultHash: vaultEntry?.valueHash,
|
|
7849
|
+
lastPushedHash: stateEntry?.lastPushedHash,
|
|
7850
|
+
onWorker: onWorkerSet.has(name)
|
|
7851
|
+
}));
|
|
7852
|
+
}
|
|
7853
|
+
for (const name of workerSecretNames) {
|
|
7854
|
+
if (required$1.includes(name)) continue;
|
|
7855
|
+
const stateKey = secretStateKey(workerKey, name);
|
|
7856
|
+
if (seen.has(stateKey)) continue;
|
|
7857
|
+
const stateEntry = secretStateByKey.get(stateKey);
|
|
7858
|
+
const vaultEntry = await vault.get(name);
|
|
7859
|
+
if (stateEntry?.lastPushedHash && !vaultEntry) {
|
|
7860
|
+
push({
|
|
7861
|
+
worker: workerKey,
|
|
7862
|
+
name,
|
|
7863
|
+
status: "removed_from_vault",
|
|
7864
|
+
lastPushedHash: stateEntry.lastPushedHash,
|
|
7865
|
+
onWorker: true
|
|
7866
|
+
});
|
|
7867
|
+
continue;
|
|
7868
|
+
}
|
|
7869
|
+
push({
|
|
7870
|
+
worker: workerKey,
|
|
7871
|
+
name,
|
|
7872
|
+
status: "undeclared_on_worker",
|
|
7873
|
+
onWorker: true
|
|
7874
|
+
});
|
|
7875
|
+
}
|
|
7876
|
+
}
|
|
7877
|
+
for (const [key, stateEntry] of secretStateByKey) {
|
|
7878
|
+
if (seen.has(key)) continue;
|
|
7879
|
+
if (await vault.get(stateEntry.name)) continue;
|
|
7880
|
+
push({
|
|
7881
|
+
worker: stateEntry.worker,
|
|
7882
|
+
name: stateEntry.name,
|
|
7883
|
+
status: "removed_from_vault",
|
|
7884
|
+
lastPushedHash: stateEntry.lastPushedHash,
|
|
7885
|
+
onWorker: workers.some((w) => w.workerKey === stateEntry.worker && w.workerSecretNames.includes(stateEntry.name))
|
|
7886
|
+
});
|
|
7887
|
+
}
|
|
7888
|
+
return entries;
|
|
7889
|
+
}
|
|
7890
|
+
function classifyDeclaredSecret(args$1) {
|
|
7891
|
+
const { worker, name, vaultHash, lastPushedHash, onWorker } = args$1;
|
|
7892
|
+
if (!vaultHash) return {
|
|
7893
|
+
worker,
|
|
7894
|
+
name,
|
|
7895
|
+
status: "declared_no_value",
|
|
7896
|
+
lastPushedHash,
|
|
7897
|
+
onWorker
|
|
7898
|
+
};
|
|
7899
|
+
if (!lastPushedHash) return {
|
|
7900
|
+
worker,
|
|
7901
|
+
name,
|
|
7902
|
+
status: "never_deployed",
|
|
7903
|
+
vaultHash,
|
|
7904
|
+
onWorker
|
|
7905
|
+
};
|
|
7906
|
+
if (vaultHash !== lastPushedHash) return {
|
|
7907
|
+
worker,
|
|
7908
|
+
name,
|
|
7909
|
+
status: "rotated_not_deployed",
|
|
7910
|
+
vaultHash,
|
|
7911
|
+
lastPushedHash,
|
|
7912
|
+
onWorker
|
|
7913
|
+
};
|
|
7914
|
+
if (!onWorker) return {
|
|
7915
|
+
worker,
|
|
7916
|
+
name,
|
|
7917
|
+
status: "never_deployed",
|
|
7918
|
+
vaultHash,
|
|
7919
|
+
lastPushedHash,
|
|
7920
|
+
onWorker: false
|
|
7921
|
+
};
|
|
7922
|
+
return {
|
|
7923
|
+
worker,
|
|
7924
|
+
name,
|
|
7925
|
+
status: "in_sync",
|
|
7926
|
+
vaultHash,
|
|
7927
|
+
lastPushedHash,
|
|
7928
|
+
onWorker: true
|
|
7929
|
+
};
|
|
7930
|
+
}
|
|
7931
|
+
function secretsDrift(entries) {
|
|
7932
|
+
const drift = {
|
|
7933
|
+
kind: "secret",
|
|
7934
|
+
missingFromCloudflare: [],
|
|
7935
|
+
unrecordedInState: [],
|
|
7936
|
+
undeployed: []
|
|
7937
|
+
};
|
|
7938
|
+
for (const e of entries) {
|
|
7939
|
+
if (e.status === "in_sync") continue;
|
|
7940
|
+
const derivedName = secretDerivedName(e.worker, e.name);
|
|
7941
|
+
switch (e.status) {
|
|
7942
|
+
case "declared_no_value":
|
|
7943
|
+
drift.undeployed.push({
|
|
7944
|
+
logicalName: e.name,
|
|
7945
|
+
derivedName,
|
|
7946
|
+
detail: "declared, no vault value"
|
|
7947
|
+
});
|
|
7948
|
+
break;
|
|
7949
|
+
case "never_deployed":
|
|
7950
|
+
drift.undeployed.push({
|
|
7951
|
+
logicalName: e.name,
|
|
7952
|
+
derivedName,
|
|
7953
|
+
detail: "never deployed"
|
|
7954
|
+
});
|
|
7955
|
+
break;
|
|
7956
|
+
case "rotated_not_deployed":
|
|
7957
|
+
drift.undeployed.push({
|
|
7958
|
+
logicalName: e.name,
|
|
7959
|
+
derivedName,
|
|
7960
|
+
detail: "rotated, not deployed"
|
|
7961
|
+
});
|
|
7962
|
+
break;
|
|
7963
|
+
case "removed_from_vault":
|
|
7964
|
+
drift.missingFromCloudflare.push({
|
|
7965
|
+
logicalName: e.name,
|
|
7966
|
+
derivedName,
|
|
7967
|
+
detail: "removed from vault"
|
|
7968
|
+
});
|
|
7969
|
+
break;
|
|
7970
|
+
case "undeclared_on_worker":
|
|
7971
|
+
drift.unrecordedInState.push({
|
|
7972
|
+
logicalName: e.name,
|
|
7973
|
+
derivedName,
|
|
7974
|
+
detail: "undeclared on worker"
|
|
7975
|
+
});
|
|
7976
|
+
break;
|
|
7977
|
+
}
|
|
7978
|
+
}
|
|
7979
|
+
return drift;
|
|
7980
|
+
}
|
|
7981
|
+
function secretsPlanItems(entries) {
|
|
7982
|
+
const items = [];
|
|
7983
|
+
for (const e of entries) {
|
|
7984
|
+
const derivedName = secretDerivedName(e.worker, e.name);
|
|
7985
|
+
switch (e.status) {
|
|
7986
|
+
case "never_deployed":
|
|
7987
|
+
items.push({
|
|
7988
|
+
kind: "secret",
|
|
7989
|
+
action: "create",
|
|
7990
|
+
logicalName: e.name,
|
|
7991
|
+
derivedName,
|
|
7992
|
+
detail: "never deployed"
|
|
7993
|
+
});
|
|
7994
|
+
break;
|
|
7995
|
+
case "rotated_not_deployed":
|
|
7996
|
+
items.push({
|
|
7997
|
+
kind: "secret",
|
|
7998
|
+
action: "update",
|
|
7999
|
+
logicalName: e.name,
|
|
8000
|
+
derivedName,
|
|
8001
|
+
detail: "rotated, not deployed",
|
|
8002
|
+
changes: [{
|
|
8003
|
+
field: "lastPushedHash",
|
|
8004
|
+
from: e.lastPushedHash,
|
|
8005
|
+
to: e.vaultHash,
|
|
8006
|
+
kind: "mutable"
|
|
8007
|
+
}]
|
|
8008
|
+
});
|
|
8009
|
+
break;
|
|
8010
|
+
default: break;
|
|
8011
|
+
}
|
|
8012
|
+
}
|
|
8013
|
+
return items;
|
|
8014
|
+
}
|
|
8015
|
+
/** In-memory vault stub for tests and pre-WS1 integration. */
|
|
8016
|
+
function vaultReaderFromMap(secrets) {
|
|
8017
|
+
const map = secrets instanceof Map ? secrets : new Map(Object.entries(secrets).map(([name, meta$2]) => [name, meta$2]));
|
|
8018
|
+
return { async get(name) {
|
|
8019
|
+
return map.get(name);
|
|
8020
|
+
} };
|
|
8021
|
+
}
|
|
8022
|
+
|
|
8023
|
+
//#endregion
|
|
8024
|
+
//#region src/cli/commands/secrets/verify.ts
|
|
8025
|
+
const STATUS_LABEL = {
|
|
8026
|
+
in_sync: "in sync",
|
|
8027
|
+
declared_no_value: "declared, no vault value",
|
|
8028
|
+
never_deployed: "never deployed",
|
|
8029
|
+
rotated_not_deployed: "rotated, not deployed",
|
|
8030
|
+
removed_from_vault: "removed from vault",
|
|
8031
|
+
undeclared_on_worker: "undeclared on worker"
|
|
8032
|
+
};
|
|
8033
|
+
async function runSecretsVerify(options) {
|
|
8034
|
+
const ctx = await createSecretsContext({
|
|
8035
|
+
env: options.env,
|
|
8036
|
+
configPath: options.configPath
|
|
8037
|
+
});
|
|
8038
|
+
const workers = await collectSecretWorkers(ctx);
|
|
8039
|
+
if (workers.length === 0) {
|
|
8040
|
+
console.log(`secrets verify (${ctx.env}): no workers declare secrets.required`);
|
|
8041
|
+
return 0;
|
|
8042
|
+
}
|
|
8043
|
+
const entries = await reconcileSecrets({
|
|
8044
|
+
workers: workers.map((w) => ({
|
|
8045
|
+
workerKey: w.workerKey,
|
|
8046
|
+
required: w.required,
|
|
8047
|
+
workerSecretNames: w.workerSecretNames
|
|
8048
|
+
})),
|
|
8049
|
+
vault: vaultReaderFromVault(ctx.vault),
|
|
8050
|
+
state: ctx.state
|
|
8051
|
+
});
|
|
8052
|
+
console.log(`\nSecrets verify — env ${ctx.env}\n`);
|
|
8053
|
+
if (entries.length === 0) {
|
|
8054
|
+
console.log(" (no declared secrets)\n");
|
|
8055
|
+
return 0;
|
|
8056
|
+
}
|
|
8057
|
+
let issues = 0;
|
|
8058
|
+
for (const entry of entries.sort((a, b) => secretDerivedName(a.worker, a.name).localeCompare(secretDerivedName(b.worker, b.name)))) {
|
|
8059
|
+
const label = STATUS_LABEL[entry.status];
|
|
8060
|
+
const id = secretDerivedName(entry.worker, entry.name);
|
|
8061
|
+
const workerFlag = entry.onWorker ? "on worker" : "not on worker";
|
|
8062
|
+
console.log(` ${id} ${label} (${workerFlag})`);
|
|
8063
|
+
if (entry.status !== "in_sync") issues += 1;
|
|
8064
|
+
}
|
|
8065
|
+
console.log(issues === 0 ? "\nAll declared secrets in sync.\n" : `\n${issues} secret(s) need attention.\n`);
|
|
8066
|
+
return issues === 0 ? 0 : 1;
|
|
8067
|
+
}
|
|
8068
|
+
|
|
8069
|
+
//#endregion
|
|
8070
|
+
//#region src/cli/commands/secrets/push.ts
|
|
8071
|
+
/**
|
|
8072
|
+
|
|
8073
|
+
* Push stale/new required secrets for one worker via the CF API.
|
|
8074
|
+
|
|
8075
|
+
* Skips secrets whose vault hash matches `lastPushedHash` in state.
|
|
8076
|
+
|
|
8077
|
+
*/
|
|
8078
|
+
async function pushSecretsForDeploy(options) {
|
|
8079
|
+
const { workerKey, deployedName, required: required$1, vault, state, api, masterKey, logPushes = false } = options;
|
|
8080
|
+
if (required$1.length === 0) return {
|
|
8081
|
+
pushed: 0,
|
|
8082
|
+
skipped: 0
|
|
8083
|
+
};
|
|
8084
|
+
let pushed = 0;
|
|
8085
|
+
let skipped = 0;
|
|
8086
|
+
for (const secretName of required$1) {
|
|
8087
|
+
const vaultRow = await vault.get(secretName);
|
|
8088
|
+
if (!vaultRow) throw new Error(`secret "${secretName}" required by worker "${workerKey}" is missing from vault; run tamer secrets set ${secretName}`);
|
|
8089
|
+
const stateKey = secretStateKey(workerKey, secretName);
|
|
8090
|
+
const stateEntry = state.get(stateKey);
|
|
8091
|
+
if ((stateEntry?.type === "secret" ? stateEntry.lastPushedHash : void 0) === vaultRow.valueHash) {
|
|
8092
|
+
skipped += 1;
|
|
8093
|
+
continue;
|
|
8094
|
+
}
|
|
8095
|
+
const plaintext = await decryptSecretValue({
|
|
8096
|
+
ciphertext: vaultRow.ciphertext,
|
|
8097
|
+
iv: vaultRow.iv,
|
|
8098
|
+
wrappedDek: vaultRow.wrappedDek,
|
|
8099
|
+
dekIv: vaultRow.dekIv
|
|
8100
|
+
}, masterKey);
|
|
8101
|
+
await api.workersSecretPut(deployedName, {
|
|
8102
|
+
name: secretName,
|
|
8103
|
+
text: plaintext
|
|
8104
|
+
});
|
|
8105
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8106
|
+
state.set(stateKey, {
|
|
8107
|
+
type: "secret",
|
|
8108
|
+
worker: workerKey,
|
|
8109
|
+
name: secretName,
|
|
8110
|
+
lastPushedHash: vaultRow.valueHash,
|
|
8111
|
+
lastPushedAt: now
|
|
8112
|
+
});
|
|
8113
|
+
pushed += 1;
|
|
8114
|
+
if (logPushes) console.log(`secrets push: ${workerKey}:${secretName} → ${deployedName}`);
|
|
8115
|
+
}
|
|
8116
|
+
return {
|
|
8117
|
+
pushed,
|
|
8118
|
+
skipped
|
|
8119
|
+
};
|
|
8120
|
+
}
|
|
8121
|
+
async function runSecretsPush(options) {
|
|
8122
|
+
const ctx = await createSecretsContext({
|
|
8123
|
+
env: options.env,
|
|
8124
|
+
configPath: options.configPath
|
|
8125
|
+
});
|
|
8126
|
+
const workers = await collectSecretWorkers(ctx, options.worker);
|
|
8127
|
+
if (workers.length === 0) {
|
|
8128
|
+
const hint = options.worker ? `worker "${options.worker}" has no secrets.required` : "no workers declare secrets.required";
|
|
8129
|
+
console.log(`secrets push (${ctx.env}): ${hint}`);
|
|
8130
|
+
return;
|
|
8131
|
+
}
|
|
8132
|
+
let pushed = 0;
|
|
8133
|
+
let skipped = 0;
|
|
8134
|
+
for (const worker of workers) {
|
|
8135
|
+
const result = await pushSecretsForDeploy({
|
|
8136
|
+
workerKey: worker.workerKey,
|
|
8137
|
+
deployedName: worker.deployedName,
|
|
8138
|
+
required: worker.required,
|
|
8139
|
+
vault: ctx.vault,
|
|
8140
|
+
state: ctx.state,
|
|
8141
|
+
api: ctx.api,
|
|
8142
|
+
masterKey: ctx.masterKey,
|
|
8143
|
+
logPushes: true
|
|
8144
|
+
});
|
|
8145
|
+
pushed += result.pushed;
|
|
8146
|
+
skipped += result.skipped;
|
|
8147
|
+
}
|
|
8148
|
+
if (pushed > 0) await ctx.state.persist(ctx.api);
|
|
8149
|
+
console.log(`secrets push (${ctx.env}): ${pushed} pushed, ${skipped} already current`);
|
|
8150
|
+
}
|
|
8151
|
+
|
|
8152
|
+
//#endregion
|
|
8153
|
+
//#region src/cli/commands/secrets/index.ts
|
|
8154
|
+
const SECRETS_USAGE = `usage:
|
|
8155
|
+
tamer secrets init --env <env> [--config <path>]
|
|
8156
|
+
tamer secrets set <NAME> --env <env> [--config <path>] # value on stdin (pipe only)
|
|
8157
|
+
tamer secrets load --file <path> --env <env> [--config <path>]
|
|
8158
|
+
tamer secrets get <NAME> --env <env> [--config <path>] # confirmation + audit log
|
|
8159
|
+
tamer secrets list --env <env> [--config <path>]
|
|
8160
|
+
tamer secrets rm <NAME> --env <env> [--config <path>]
|
|
8161
|
+
tamer secrets verify --env <env> [--config <path>]
|
|
8162
|
+
tamer secrets push --env <env> [--worker <name>] [--config <path>]`;
|
|
8163
|
+
function parseSecretsArgs(argv) {
|
|
8164
|
+
const positional = [];
|
|
8165
|
+
const opts = {};
|
|
8166
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8167
|
+
const arg = argv[i];
|
|
8168
|
+
if (arg.startsWith("--")) {
|
|
8169
|
+
const key = arg.slice(2).replace(/-/g, "_");
|
|
8170
|
+
const next = argv[i + 1];
|
|
8171
|
+
if (next && !next.startsWith("--")) {
|
|
8172
|
+
opts[key] = next;
|
|
8173
|
+
i++;
|
|
8174
|
+
} else opts[key] = true;
|
|
8175
|
+
} else positional.push(arg);
|
|
8176
|
+
}
|
|
8177
|
+
return {
|
|
8178
|
+
subcommand: positional[0],
|
|
8179
|
+
name: positional[1],
|
|
8180
|
+
env: opts.env,
|
|
8181
|
+
configPath: opts.config,
|
|
8182
|
+
file: opts.file,
|
|
8183
|
+
worker: opts.worker,
|
|
8184
|
+
yes: opts.yes === true
|
|
8185
|
+
};
|
|
8186
|
+
}
|
|
8187
|
+
async function runSecrets(argv) {
|
|
8188
|
+
const parsed = parseSecretsArgs(argv);
|
|
8189
|
+
const { subcommand } = parsed;
|
|
8190
|
+
switch (subcommand) {
|
|
8191
|
+
case "init":
|
|
8192
|
+
await runSecretsInit({
|
|
8193
|
+
env: parsed.env,
|
|
8194
|
+
configPath: parsed.configPath
|
|
8195
|
+
});
|
|
8196
|
+
return 0;
|
|
8197
|
+
case "set":
|
|
8198
|
+
await runSecretsSet({
|
|
8199
|
+
name: parsed.name,
|
|
8200
|
+
env: parsed.env,
|
|
8201
|
+
configPath: parsed.configPath
|
|
8202
|
+
});
|
|
8203
|
+
return 0;
|
|
8204
|
+
case "load":
|
|
8205
|
+
if (!parsed.file) throw new Error("secrets load requires --file <path>");
|
|
8206
|
+
await runSecretsLoad({
|
|
8207
|
+
file: parsed.file,
|
|
8208
|
+
env: parsed.env,
|
|
8209
|
+
configPath: parsed.configPath
|
|
8210
|
+
});
|
|
8211
|
+
return 0;
|
|
8212
|
+
case "get":
|
|
8213
|
+
await runSecretsGet({
|
|
8214
|
+
name: parsed.name,
|
|
8215
|
+
env: parsed.env,
|
|
8216
|
+
configPath: parsed.configPath,
|
|
8217
|
+
yes: parsed.yes
|
|
8218
|
+
});
|
|
8219
|
+
return 0;
|
|
8220
|
+
case "list":
|
|
8221
|
+
await runSecretsList({
|
|
8222
|
+
env: parsed.env,
|
|
8223
|
+
configPath: parsed.configPath
|
|
8224
|
+
});
|
|
8225
|
+
return 0;
|
|
8226
|
+
case "rm":
|
|
8227
|
+
await runSecretsRm({
|
|
8228
|
+
name: parsed.name,
|
|
8229
|
+
env: parsed.env,
|
|
8230
|
+
configPath: parsed.configPath
|
|
8231
|
+
});
|
|
8232
|
+
return 0;
|
|
8233
|
+
case "verify": return runSecretsVerify({
|
|
8234
|
+
env: parsed.env,
|
|
8235
|
+
configPath: parsed.configPath
|
|
8236
|
+
});
|
|
8237
|
+
case "push":
|
|
8238
|
+
await runSecretsPush({
|
|
8239
|
+
env: parsed.env,
|
|
8240
|
+
configPath: parsed.configPath,
|
|
8241
|
+
worker: parsed.worker
|
|
8242
|
+
});
|
|
8243
|
+
return 0;
|
|
8244
|
+
default:
|
|
8245
|
+
console.error(SECRETS_USAGE);
|
|
8246
|
+
return 1;
|
|
8247
|
+
}
|
|
8248
|
+
}
|
|
8249
|
+
|
|
8250
|
+
//#endregion
|
|
8251
|
+
//#region src/cli/args.ts
|
|
8252
|
+
const BaseArgsSchema = object({
|
|
8253
|
+
env: string().optional(),
|
|
8254
|
+
config: string().optional(),
|
|
8255
|
+
worker: string().optional()
|
|
8256
|
+
});
|
|
8257
|
+
const ApplyArgsSchema = BaseArgsSchema.extend({
|
|
8258
|
+
add_shard: string().optional(),
|
|
8259
|
+
plan: string().optional(),
|
|
8260
|
+
allow_stale: boolean().optional(),
|
|
8261
|
+
rollback_on_failure: boolean().optional(),
|
|
8262
|
+
target: string().optional()
|
|
8263
|
+
});
|
|
8264
|
+
const DestroyArgsSchema = BaseArgsSchema.extend({
|
|
8265
|
+
env: string().min(1, { error: "env is required for destroy" }),
|
|
8266
|
+
force: boolean().optional(),
|
|
8267
|
+
skip_workers: boolean().optional(),
|
|
8268
|
+
confirm_env: string().optional(),
|
|
8269
|
+
wipe_metadata: boolean().optional(),
|
|
8270
|
+
plan: string().optional(),
|
|
8271
|
+
allow_stale: boolean().optional()
|
|
8272
|
+
});
|
|
8273
|
+
const BootstrapArgsSchema = object({
|
|
8274
|
+
env: string().min(1, { error: "env is required for bootstrap" }),
|
|
8275
|
+
config: string().optional()
|
|
8276
|
+
});
|
|
8277
|
+
const DriftArgsSchema = BaseArgsSchema.extend({ json: boolean().optional() });
|
|
8278
|
+
const PlanArgsSchema = BaseArgsSchema.extend({
|
|
8279
|
+
json: boolean().optional(),
|
|
8280
|
+
detailed_exitcode: boolean().optional(),
|
|
8281
|
+
out: string().optional(),
|
|
8282
|
+
destroy: boolean().optional(),
|
|
8283
|
+
target: string().optional()
|
|
8284
|
+
});
|
|
8285
|
+
const ImportArgsSchema = object({
|
|
8286
|
+
env: string().min(1, { error: "env is required for import" }),
|
|
8287
|
+
config: string().optional(),
|
|
8288
|
+
kind: _enum([
|
|
8289
|
+
"d1",
|
|
8290
|
+
"r2",
|
|
8291
|
+
"kv",
|
|
8292
|
+
"queue",
|
|
8293
|
+
"hyperdrive",
|
|
8294
|
+
"vectorize",
|
|
8295
|
+
"ai_gateway",
|
|
8296
|
+
"pipeline",
|
|
8297
|
+
"workflow",
|
|
8298
|
+
"secret_store",
|
|
8299
|
+
"dns_record",
|
|
8300
|
+
"dispatch_namespace",
|
|
8301
|
+
"worker_route"
|
|
8302
|
+
], { error: "kind must be one of d1 | r2 | kv | queue | hyperdrive | vectorize | ai_gateway | pipeline | workflow | secret_store | dns_record | dispatch_namespace | worker_route" }),
|
|
8303
|
+
logical: string().min(1, { error: "logical is required" }),
|
|
8304
|
+
cf_id: string().optional(),
|
|
8305
|
+
shard_date: string().optional(),
|
|
8306
|
+
created_date: string().optional(),
|
|
8307
|
+
route_id: string().optional(),
|
|
8308
|
+
zone_name: string().optional()
|
|
8309
|
+
});
|
|
8310
|
+
const StatusArgsSchema = BaseArgsSchema.extend({ tenant: string().optional() });
|
|
8311
|
+
const EventsArgsSchema = BaseArgsSchema.extend({
|
|
8312
|
+
json: boolean().optional(),
|
|
8313
|
+
limit: number().int().min(1).max(100).optional()
|
|
8314
|
+
});
|
|
8315
|
+
const DoctorArgsSchema = object({ json: boolean().optional() });
|
|
8316
|
+
const ProvisionTenantArgsSchema = object({
|
|
8317
|
+
env: string().min(1, { error: "env is required" }),
|
|
8318
|
+
product: string().min(1, { error: "product is required" }),
|
|
8319
|
+
workspace: string().min(1, { error: "workspace is required" }),
|
|
8320
|
+
main: string().optional(),
|
|
8321
|
+
artifact_key: string().optional(),
|
|
8322
|
+
module_name: string().optional(),
|
|
8323
|
+
config: string().optional(),
|
|
8324
|
+
compatibility_date: string().optional(),
|
|
8325
|
+
compat_flags: string().optional(),
|
|
8326
|
+
shards: string().optional(),
|
|
8327
|
+
json: boolean().optional()
|
|
8328
|
+
}).refine((d) => !!(d.main || d.artifact_key), { message: "Provide --main <file> or --artifact-key <r2-key> (under tamer-artifacts-{env})" });
|
|
8329
|
+
const DestroyTenantArgsSchema = object({
|
|
8330
|
+
env: string().min(1, { error: "env is required" }),
|
|
8331
|
+
product: string().min(1, { error: "product is required" }),
|
|
8332
|
+
workspace: string().min(1, { error: "workspace is required" }),
|
|
8333
|
+
force: boolean().optional(),
|
|
8334
|
+
confirm_tenant: string().optional(),
|
|
8335
|
+
config: string().optional(),
|
|
8336
|
+
json: boolean().optional()
|
|
8337
|
+
});
|
|
8338
|
+
const DeployArgsSchema = BaseArgsSchema.extend({ dispatch_namespace: string().optional() });
|
|
8339
|
+
const DevArgsSchema = BaseArgsSchema.extend({ all: boolean().optional() });
|
|
8340
|
+
function parseArgs(argv) {
|
|
8341
|
+
const opts = {};
|
|
8342
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8343
|
+
const arg = argv[i];
|
|
8344
|
+
if (arg.startsWith("--")) {
|
|
8345
|
+
const key = arg.slice(2).replace(/-/g, "_");
|
|
8346
|
+
const next = argv[i + 1];
|
|
8347
|
+
if (next && !next.startsWith("--")) {
|
|
8348
|
+
opts[key] = next;
|
|
8349
|
+
i++;
|
|
8350
|
+
} else opts[key] = true;
|
|
8351
|
+
}
|
|
8352
|
+
}
|
|
8353
|
+
return opts;
|
|
8354
|
+
}
|
|
8355
|
+
function toOpts(raw) {
|
|
8356
|
+
const opts = {};
|
|
8357
|
+
for (const [k, v] of Object.entries(raw)) opts[k] = v;
|
|
8358
|
+
return opts;
|
|
8359
|
+
}
|
|
8360
|
+
function formatZodError(error) {
|
|
8361
|
+
return error.issues.map((e) => e.path.length ? `${e.path.join(".")}: ${e.message}` : e.message).join("; ");
|
|
8362
|
+
}
|
|
8363
|
+
function parseSyncArgs(argv) {
|
|
8364
|
+
const parsed = BaseArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
8365
|
+
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
8366
|
+
return {
|
|
8367
|
+
env: parsed.data.env,
|
|
8368
|
+
configPath: parsed.data.config
|
|
8369
|
+
};
|
|
8370
|
+
}
|
|
8371
|
+
function parseApplyArgs(argv) {
|
|
8372
|
+
const parsed = ApplyArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
8373
|
+
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
8374
|
+
return {
|
|
8375
|
+
env: parsed.data.env,
|
|
8376
|
+
addShard: parsed.data.add_shard,
|
|
8377
|
+
configPath: parsed.data.config,
|
|
8378
|
+
planFile: parsed.data.plan,
|
|
8379
|
+
allowStale: parsed.data.allow_stale,
|
|
8380
|
+
rollbackOnFailure: parsed.data.rollback_on_failure,
|
|
8381
|
+
target: parsed.data.target
|
|
8382
|
+
};
|
|
8383
|
+
}
|
|
8384
|
+
function parseDevArgs(argv) {
|
|
8385
|
+
const parsed = DevArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
8386
|
+
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
8387
|
+
return {
|
|
8388
|
+
worker: parsed.data.worker,
|
|
8389
|
+
env: parsed.data.env,
|
|
8390
|
+
configPath: parsed.data.config,
|
|
8391
|
+
all: parsed.data.all
|
|
8392
|
+
};
|
|
8393
|
+
}
|
|
8394
|
+
function parseDeployArgs(argv) {
|
|
8395
|
+
const parsed = DeployArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
8396
|
+
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
8397
|
+
return {
|
|
8398
|
+
worker: parsed.data.worker,
|
|
8399
|
+
env: parsed.data.env,
|
|
8400
|
+
configPath: parsed.data.config,
|
|
8401
|
+
dispatchNamespace: parsed.data.dispatch_namespace
|
|
8402
|
+
};
|
|
8403
|
+
}
|
|
8404
|
+
function parseMigrateArgs(argv) {
|
|
8405
|
+
const parsed = BaseArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
8406
|
+
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
8407
|
+
return {
|
|
8408
|
+
worker: parsed.data.worker,
|
|
8409
|
+
env: parsed.data.env,
|
|
8410
|
+
configPath: parsed.data.config
|
|
8411
|
+
};
|
|
8412
|
+
}
|
|
8413
|
+
function parseTypesArgs(argv) {
|
|
8414
|
+
const parsed = BaseArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
8415
|
+
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
8416
|
+
return {
|
|
8417
|
+
worker: parsed.data.worker,
|
|
8418
|
+
env: parsed.data.env,
|
|
8419
|
+
configPath: parsed.data.config
|
|
8420
|
+
};
|
|
8421
|
+
}
|
|
8422
|
+
function parseStatusArgs(argv) {
|
|
8423
|
+
const parsed = StatusArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
8424
|
+
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
8425
|
+
return {
|
|
8426
|
+
env: parsed.data.env,
|
|
8427
|
+
configPath: parsed.data.config,
|
|
8428
|
+
tenant: parsed.data.tenant
|
|
8429
|
+
};
|
|
8430
|
+
}
|
|
8431
|
+
function parseEventsArgs(argv) {
|
|
8432
|
+
const parsed = EventsArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
8433
|
+
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
8434
|
+
return {
|
|
8435
|
+
env: parsed.data.env,
|
|
8436
|
+
configPath: parsed.data.config,
|
|
8437
|
+
limit: parsed.data.limit,
|
|
8438
|
+
json: parsed.data.json
|
|
8439
|
+
};
|
|
8440
|
+
}
|
|
8441
|
+
function parseDoctorArgs(argv) {
|
|
8442
|
+
const parsed = DoctorArgsSchema.safeParse(toOpts(parseArgs(argv)));
|
|
4231
8443
|
if (!parsed.success) throw new Error(`Invalid arguments: ${formatZodError(parsed.error)}`);
|
|
4232
8444
|
return { json: parsed.data.json };
|
|
4233
8445
|
}
|
|
@@ -4325,17 +8537,6 @@ function parseBootstrapArgs(argv) {
|
|
|
4325
8537
|
};
|
|
4326
8538
|
}
|
|
4327
8539
|
|
|
4328
|
-
//#endregion
|
|
4329
|
-
//#region src/core/state/StateConflictError.ts
|
|
4330
|
-
/** Thrown when D1 `cfi_state` was updated by another writer since {@link StateManager.hydrate}. */
|
|
4331
|
-
var StateConflictError = class extends Error {
|
|
4332
|
-
code = "STATE_CONFLICT";
|
|
4333
|
-
constructor(message) {
|
|
4334
|
-
super(message);
|
|
4335
|
-
this.name = "StateConflictError";
|
|
4336
|
-
}
|
|
4337
|
-
};
|
|
4338
|
-
|
|
4339
8540
|
//#endregion
|
|
4340
8541
|
//#region src/cli/index.ts
|
|
4341
8542
|
const args = process.argv.slice(2);
|
|
@@ -4346,14 +8547,14 @@ async function main() {
|
|
|
4346
8547
|
try {
|
|
4347
8548
|
switch (command) {
|
|
4348
8549
|
case "bootstrap":
|
|
4349
|
-
await import("./bootstrap-
|
|
8550
|
+
await import("./bootstrap-D__dHw1w.mjs").then((m) => m.runBootstrap(parseBootstrapArgs(rest)));
|
|
4350
8551
|
break;
|
|
4351
8552
|
case "sync":
|
|
4352
|
-
await import("./sync-
|
|
8553
|
+
await import("./sync-CpfxqlOx.mjs").then((m) => m.runSync(parseSyncArgs(rest)));
|
|
4353
8554
|
break;
|
|
4354
8555
|
case "apply": {
|
|
4355
8556
|
const a = parseApplyArgs(rest);
|
|
4356
|
-
await import("./apply-
|
|
8557
|
+
await import("./apply-BjrYbyHn.mjs").then((m) => m.runApply({
|
|
4357
8558
|
env: a.env,
|
|
4358
8559
|
addShard: a.addShard,
|
|
4359
8560
|
configPath: a.configPath,
|
|
@@ -4365,11 +8566,11 @@ async function main() {
|
|
|
4365
8566
|
break;
|
|
4366
8567
|
}
|
|
4367
8568
|
case "dev":
|
|
4368
|
-
await import("./dev-
|
|
8569
|
+
await import("./dev-BLthyLml.mjs").then((m) => m.runDev(parseDevArgs(rest)));
|
|
4369
8570
|
break;
|
|
4370
8571
|
case "deploy": {
|
|
4371
8572
|
const d = parseDeployArgs(rest);
|
|
4372
|
-
await import("./deploy-
|
|
8573
|
+
await import("./deploy-C6fX9td0.mjs").then((m) => m.runDeploy({
|
|
4373
8574
|
worker: d.worker,
|
|
4374
8575
|
env: d.env,
|
|
4375
8576
|
configPath: d.configPath,
|
|
@@ -4378,23 +8579,23 @@ async function main() {
|
|
|
4378
8579
|
break;
|
|
4379
8580
|
}
|
|
4380
8581
|
case "migrate":
|
|
4381
|
-
await import("./migrate-
|
|
8582
|
+
await import("./migrate-CroDjbJz.mjs").then((m) => m.runMigrate(parseMigrateArgs(rest)));
|
|
4382
8583
|
break;
|
|
4383
8584
|
case "types":
|
|
4384
|
-
await import("./types-
|
|
8585
|
+
await import("./types-BzzHwIdw.mjs").then((m) => m.runTypes(parseTypesArgs(rest)));
|
|
4385
8586
|
break;
|
|
4386
8587
|
case "status":
|
|
4387
|
-
await import("./status-
|
|
8588
|
+
await import("./status-DkkS5lc9.mjs").then((m) => m.runStatus(parseStatusArgs(rest)));
|
|
4388
8589
|
break;
|
|
4389
8590
|
case "events":
|
|
4390
|
-
await import("./events-
|
|
8591
|
+
await import("./events-otk0l3aJ.mjs").then((m) => m.runEvents(parseEventsArgs(rest)));
|
|
4391
8592
|
break;
|
|
4392
8593
|
case "drift":
|
|
4393
|
-
exitStatus = await import("./drift-
|
|
8594
|
+
exitStatus = await import("./drift-BCxWdYHG.mjs").then((m) => m.runDrift(parseDriftArgs(rest)));
|
|
4394
8595
|
break;
|
|
4395
8596
|
case "plan": {
|
|
4396
8597
|
const p = parsePlanArgs(rest);
|
|
4397
|
-
exitStatus = await import("./plan-
|
|
8598
|
+
exitStatus = await import("./plan-C2urqJOz.mjs").then((m) => m.runPlan({
|
|
4398
8599
|
env: p.env,
|
|
4399
8600
|
configPath: p.configPath,
|
|
4400
8601
|
json: p.json,
|
|
@@ -4406,14 +8607,14 @@ async function main() {
|
|
|
4406
8607
|
break;
|
|
4407
8608
|
}
|
|
4408
8609
|
case "import":
|
|
4409
|
-
await import("./import-
|
|
8610
|
+
await import("./import-OvohE-H2.mjs").then((m) => m.runImport(parseImportArgs(rest)));
|
|
4410
8611
|
break;
|
|
4411
8612
|
case "doctor":
|
|
4412
|
-
exitStatus = await import("./doctor-
|
|
8613
|
+
exitStatus = await import("./doctor-32YLAXXl.mjs").then((m) => m.runDoctor(parseDoctorArgs(rest)));
|
|
4413
8614
|
break;
|
|
4414
8615
|
case "provision-tenant": {
|
|
4415
8616
|
const p = parseProvisionTenantArgs(rest);
|
|
4416
|
-
await import("./provision-tenant-
|
|
8617
|
+
await import("./provision-tenant-BJ1KugON.mjs").then((m) => m.runProvisionTenant({
|
|
4417
8618
|
env: p.env,
|
|
4418
8619
|
product: p.product,
|
|
4419
8620
|
workspace: p.workspace,
|
|
@@ -4430,7 +8631,7 @@ async function main() {
|
|
|
4430
8631
|
}
|
|
4431
8632
|
case "destroy-tenant": {
|
|
4432
8633
|
const t = parseDestroyTenantArgs(rest);
|
|
4433
|
-
await import("./destroy-tenant-
|
|
8634
|
+
await import("./destroy-tenant-T_94ed9x.mjs").then((m) => m.runDestroyTenant({
|
|
4434
8635
|
env: t.env,
|
|
4435
8636
|
product: t.product,
|
|
4436
8637
|
workspace: t.workspace,
|
|
@@ -4443,7 +8644,7 @@ async function main() {
|
|
|
4443
8644
|
}
|
|
4444
8645
|
case "destroy": {
|
|
4445
8646
|
const d = parseDestroyArgs(rest);
|
|
4446
|
-
await import("./destroy-
|
|
8647
|
+
await import("./destroy-vfk2Zbfj.mjs").then((m) => m.runDestroy({
|
|
4447
8648
|
env: d.env,
|
|
4448
8649
|
force: d.force,
|
|
4449
8650
|
skipWorkers: d.skipWorkers,
|
|
@@ -4458,18 +8659,21 @@ async function main() {
|
|
|
4458
8659
|
case "wfp": {
|
|
4459
8660
|
const [sub, ...wfpRest] = rest;
|
|
4460
8661
|
if (sub === "put") {
|
|
4461
|
-
const { parseWfpPutArgs, runWfpPut } = await import("./wfp-put-
|
|
8662
|
+
const { parseWfpPutArgs, runWfpPut } = await import("./wfp-put-BrwICc9i.mjs");
|
|
4462
8663
|
await runWfpPut(parseWfpPutArgs(wfpRest));
|
|
4463
8664
|
break;
|
|
4464
8665
|
}
|
|
4465
8666
|
if (sub === "delete") {
|
|
4466
|
-
const { parseWfpDeleteArgs, runWfpDelete } = await import("./wfp-delete-
|
|
8667
|
+
const { parseWfpDeleteArgs, runWfpDelete } = await import("./wfp-delete-CDBFqmrM.mjs");
|
|
4467
8668
|
await runWfpDelete(parseWfpDeleteArgs(wfpRest));
|
|
4468
8669
|
break;
|
|
4469
8670
|
}
|
|
4470
8671
|
console.error("usage:\n tamer wfp put --namespace <n> --script-name <s> --main <file> [--compatibility-date <d>] [--compat-flags a,b] [--config <path>]\n tamer wfp delete --namespace <n> --script-name <s> [--force] [--config <path>]");
|
|
4471
8672
|
process.exit(1);
|
|
4472
8673
|
}
|
|
8674
|
+
case "secrets":
|
|
8675
|
+
exitStatus = await import("./secrets-CnzjvndT.mjs").then((m) => m.runSecrets(rest));
|
|
8676
|
+
break;
|
|
4473
8677
|
case "help":
|
|
4474
8678
|
case "-h":
|
|
4475
8679
|
case "--help":
|
|
@@ -4496,6 +8700,7 @@ Commands:
|
|
|
4496
8700
|
provision-tenant Runtime: create per-tenant D1 shards declared in tenant.d1Shards + upload dispatch script (--main or --artifact-key); --shards a,b trims to a subset of the configured layout (omit for all); --json emits machine-readable result for Cloudflare Container callers
|
|
4497
8701
|
destroy-tenant Runtime: remove tenant script + D1 + state (shared envs need --confirm-tenant); --json emits machine-readable result
|
|
4498
8702
|
destroy Delete all resources for an env (use with caution)
|
|
8703
|
+
secrets Encrypted secrets vault (init, set, load, get, list, rm, verify, push)
|
|
4499
8704
|
wfp put Upload a single-module user Worker to a dispatch namespace (multipart API)
|
|
4500
8705
|
wfp delete Delete a user Worker from a dispatch namespace
|
|
4501
8706
|
|
|
@@ -4522,6 +8727,16 @@ Options:
|
|
|
4522
8727
|
--route-id <id> import worker_route: route id from /zones/{id}/workers/routes
|
|
4523
8728
|
--zone-name <z> import worker_route: optional zone hint when a worker has multiple routes
|
|
4524
8729
|
|
|
8730
|
+
Secrets (requires TAMER_SECRETS_KEY_{env} master key env var):
|
|
8731
|
+
tamer secrets init --env <env> Generate master key (print once) + provision vault
|
|
8732
|
+
tamer secrets set <NAME> --env <env> Encrypt value from stdin into vault (pipe only)
|
|
8733
|
+
tamer secrets load --file .dev.vars --env <env> Bulk import; file wins over process.env
|
|
8734
|
+
tamer secrets get <NAME> --env <env> Decrypt + print (confirmation + audit log)
|
|
8735
|
+
tamer secrets list --env <env> Names + fingerprints + last-set (never values)
|
|
8736
|
+
tamer secrets rm <NAME> --env <env> Remove from vault
|
|
8737
|
+
tamer secrets verify --env <env> Reconcile declared vs vault vs pushed vs worker
|
|
8738
|
+
tamer secrets push --env <env> Push stale/new secrets to workers via CF API
|
|
8739
|
+
|
|
4525
8740
|
Environment variables (same as Wrangler):
|
|
4526
8741
|
CLOUDFLARE_ACCOUNT_ID Cloudflare account ID
|
|
4527
8742
|
CLOUDFLARE_API_TOKEN Cloudflare API token
|
|
@@ -4549,5 +8764,5 @@ Environment variables (same as Wrangler):
|
|
|
4549
8764
|
main();
|
|
4550
8765
|
|
|
4551
8766
|
//#endregion
|
|
4552
|
-
export {
|
|
8767
|
+
export { resolveReferencesInString as A, getWorkers as B, stackNameForConfig as C, resolveDeployedWorkerName as D, mergedWorkerConfigForEnv as E, ensureTamerSecretsDatabase as F, tamerSecretsDatabaseName as I, CFApiClient as L, effectiveDispatchNamespaceName as M, isEphemeralEnv as N, resolveWorkerConfig as O, namingFromConfig as P, cloudflareAccountIdFromEnv as R, tamerStateDatabaseName as S, mergeWorkerConfigForResourcePick as T, loadConfig as V, tenantShardDatabaseName as _, reconcileSecrets as a, destroyTamerStateDatabase as b, vaultReaderFromMap as c, fetchStackImports as d, importedStackNames as f, tenantDispatchScriptName as g, parseTenantShardRoles as h, pushSecretsForDeploy as i, wranglerConfigCliArgs as j, rewriteIntraStackServiceTargets as k, createDeploySecretsResources as l, StateManager as m, parseSecretsArgs as n, secretsDrift as o, scanConfigForImports as p, runSecrets as r, secretsPlanItems as s, SECRETS_USAGE as t, requiredSecretsForWorker as u, tenantStateKey as v, buildIntraStackScriptNameMap as w, ensureTamerStateDatabase as x, createEmptyCfiState as y, cloudflareApiTokenFromEnv as z };
|
|
4553
8768
|
//# sourceMappingURL=tamer.mjs.map
|