@xdelivered/emberflow 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/commands.test.ts +185 -0
- package/bin/commands.ts +306 -0
- package/bin/emberflow.mjs +38 -0
- package/bin/init.test.ts +165 -0
- package/bin/init.ts +297 -0
- package/bin/runtime.mjs +105 -0
- package/bin/runtime.test.ts +167 -0
- package/bin/skillTargets.test.ts +108 -0
- package/bin/skillTargets.ts +36 -0
- package/bin/skillTemplates.test.ts +37 -0
- package/bin/tsconfig.json +23 -0
- package/dist/bin/commands.d.ts +24 -0
- package/dist/bin/commands.js +252 -0
- package/dist/bin/init.d.ts +17 -0
- package/dist/bin/init.js +241 -0
- package/dist/bin/runtime.d.mts +39 -0
- package/dist/bin/runtime.mjs +96 -0
- package/dist/bin/skillTargets.d.ts +13 -0
- package/dist/bin/skillTargets.js +22 -0
- package/dist/server/agents/claudeAdapter.d.ts +8 -0
- package/dist/server/agents/claudeAdapter.js +190 -0
- package/dist/server/agents/claudeParse.d.ts +2 -0
- package/dist/server/agents/claudeParse.js +162 -0
- package/dist/server/agents/codexAdapter.d.ts +13 -0
- package/dist/server/agents/codexAdapter.js +174 -0
- package/dist/server/agents/codexParse.d.ts +2 -0
- package/dist/server/agents/codexParse.js +74 -0
- package/dist/server/agents/detect.d.ts +23 -0
- package/dist/server/agents/detect.js +35 -0
- package/dist/server/agents/gitScope.d.ts +24 -0
- package/dist/server/agents/gitScope.js +95 -0
- package/dist/server/agents/modelRejectionHint.d.ts +8 -0
- package/dist/server/agents/modelRejectionHint.js +12 -0
- package/dist/server/agents/prompt.d.ts +79 -0
- package/dist/server/agents/prompt.js +364 -0
- package/dist/server/agents/runManager.d.ts +90 -0
- package/dist/server/agents/runManager.js +259 -0
- package/dist/server/agents/types.d.ts +15 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/apiStore.d.ts +60 -0
- package/dist/server/apiStore.js +249 -0
- package/dist/server/auth/enforce.d.ts +1 -0
- package/dist/server/auth/enforce.js +6 -0
- package/dist/server/auth/types.d.ts +1 -0
- package/dist/server/auth/types.js +1 -0
- package/dist/server/auth/verifiers.d.ts +1 -0
- package/dist/server/auth/verifiers.js +4 -0
- package/dist/server/authAttach.d.ts +19 -0
- package/dist/server/authAttach.js +79 -0
- package/dist/server/cli.d.ts +7 -0
- package/dist/server/cli.js +804 -0
- package/dist/server/client.d.ts +130 -0
- package/dist/server/client.js +247 -0
- package/dist/server/environments.d.ts +103 -0
- package/dist/server/environments.js +486 -0
- package/dist/server/flowStore.d.ts +37 -0
- package/dist/server/flowStore.js +117 -0
- package/dist/server/httpOperations.d.ts +32 -0
- package/dist/server/httpOperations.js +62 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1022 -0
- package/dist/server/infrastructure.d.ts +51 -0
- package/dist/server/infrastructure.js +116 -0
- package/dist/server/login.d.ts +21 -0
- package/dist/server/login.js +44 -0
- package/dist/server/mcp.d.ts +1 -0
- package/dist/server/mcp.js +182 -0
- package/dist/server/migrateFlows.d.ts +8 -0
- package/dist/server/migrateFlows.js +25 -0
- package/dist/server/mockHandler.d.ts +15 -0
- package/dist/server/mockHandler.js +35 -0
- package/dist/server/nodesPayload.d.ts +14 -0
- package/dist/server/nodesPayload.js +14 -0
- package/dist/server/normalizeFlow.d.ts +21 -0
- package/dist/server/normalizeFlow.js +48 -0
- package/dist/server/openBrowser.d.ts +8 -0
- package/dist/server/openBrowser.js +16 -0
- package/dist/server/operationResult.d.ts +12 -0
- package/dist/server/operationResult.js +26 -0
- package/dist/server/pathGuard.d.ts +1 -0
- package/dist/server/pathGuard.js +16 -0
- package/dist/server/pathSafety.d.ts +20 -0
- package/dist/server/pathSafety.js +35 -0
- package/dist/server/projectConfig.d.ts +47 -0
- package/dist/server/projectConfig.js +61 -0
- package/dist/server/projectMode.d.ts +26 -0
- package/dist/server/projectMode.js +44 -0
- package/dist/server/redact.d.ts +6 -0
- package/dist/server/redact.js +37 -0
- package/dist/server/runRegistry.d.ts +84 -0
- package/dist/server/runRegistry.js +200 -0
- package/dist/server/runtime/RuntimeAdapter.d.ts +22 -0
- package/dist/server/runtime/RuntimeAdapter.js +1 -0
- package/dist/server/runtime/expressAdapter.d.ts +19 -0
- package/dist/server/runtime/expressAdapter.js +56 -0
- package/dist/server/scenarioTest.d.ts +14 -0
- package/dist/server/scenarioTest.js +66 -0
- package/dist/server/subflowRunner.d.ts +32 -0
- package/dist/server/subflowRunner.js +72 -0
- package/dist/server/testRunner.d.ts +70 -0
- package/dist/server/testRunner.js +151 -0
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +2 -0
- package/dist/src/engine/authVerify.d.ts +33 -0
- package/dist/src/engine/authVerify.js +63 -0
- package/dist/src/engine/diagnostics.d.ts +62 -0
- package/dist/src/engine/diagnostics.js +148 -0
- package/dist/src/engine/executor.d.ts +151 -0
- package/dist/src/engine/executor.js +684 -0
- package/dist/src/engine/httpError.d.ts +7 -0
- package/dist/src/engine/httpError.js +14 -0
- package/dist/src/engine/index.d.ts +11 -0
- package/dist/src/engine/index.js +11 -0
- package/dist/src/engine/output.d.ts +11 -0
- package/dist/src/engine/output.js +20 -0
- package/dist/src/engine/publish.d.ts +30 -0
- package/dist/src/engine/publish.js +95 -0
- package/dist/src/engine/registry.d.ts +34 -0
- package/dist/src/engine/registry.js +67 -0
- package/dist/src/engine/scenarios.d.ts +12 -0
- package/dist/src/engine/scenarios.js +19 -0
- package/dist/src/engine/schemaCheck.d.ts +4 -0
- package/dist/src/engine/schemaCheck.js +34 -0
- package/dist/src/engine/trace.d.ts +12 -0
- package/dist/src/engine/trace.js +12 -0
- package/dist/src/engine/types.d.ts +308 -0
- package/dist/src/engine/types.js +1 -0
- package/dist/src/engine/validation.d.ts +26 -0
- package/dist/src/engine/validation.js +318 -0
- package/dist/src/flows/anomaly-flows.d.ts +23 -0
- package/dist/src/flows/anomaly-flows.js +151 -0
- package/dist/src/flows/city-sweep-flow.d.ts +10 -0
- package/dist/src/flows/city-sweep-flow.js +127 -0
- package/dist/src/flows/login-flow.d.ts +13 -0
- package/dist/src/flows/login-flow.js +145 -0
- package/dist/src/flows/pradar-flows.d.ts +5 -0
- package/dist/src/flows/pradar-flows.js +375 -0
- package/dist/src/flows/weather-flow.d.ts +12 -0
- package/dist/src/flows/weather-flow.js +150 -0
- package/dist/src/lib/pathParams.d.ts +3 -0
- package/dist/src/lib/pathParams.js +6 -0
- package/dist/src/nodes/anomaly.d.ts +2 -0
- package/dist/src/nodes/anomaly.js +556 -0
- package/dist/src/nodes/flow-control.d.ts +7 -0
- package/dist/src/nodes/flow-control.js +270 -0
- package/dist/src/nodes/index.d.ts +3 -0
- package/dist/src/nodes/index.js +20 -0
- package/dist/src/nodes/login.d.ts +7 -0
- package/dist/src/nodes/login.js +158 -0
- package/dist/src/nodes/loops.d.ts +11 -0
- package/dist/src/nodes/loops.js +60 -0
- package/dist/src/nodes/pradar/index.d.ts +7 -0
- package/dist/src/nodes/pradar/index.js +9 -0
- package/dist/src/nodes/pradar/logic.d.ts +189 -0
- package/dist/src/nodes/pradar/logic.js +370 -0
- package/dist/src/nodes/pradar/nodes.d.ts +42 -0
- package/dist/src/nodes/pradar/nodes.js +801 -0
- package/dist/src/nodes/requireAuth.d.ts +20 -0
- package/dist/src/nodes/requireAuth.js +48 -0
- package/dist/src/nodes/response.d.ts +8 -0
- package/dist/src/nodes/response.js +33 -0
- package/dist/src/nodes/weather.d.ts +33 -0
- package/dist/src/nodes/weather.js +260 -0
- package/package.json +92 -0
- package/server/agentRoute.test.ts +157 -0
- package/server/agents/__fixtures__/fake-claude-writer.mjs +23 -0
- package/server/agents/__fixtures__/fake-claude.mjs +55 -0
- package/server/agents/__fixtures__/fake-codex-writer.mjs +27 -0
- package/server/agents/__fixtures__/fake-codex.mjs +52 -0
- package/server/agents/__fixtures__/fake-version-empty.mjs +3 -0
- package/server/agents/__fixtures__/fake-version-fail.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-garbage.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-good.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-hang.mjs +3 -0
- package/server/agents/claudeAdapter.test.ts +127 -0
- package/server/agents/claudeAdapter.ts +212 -0
- package/server/agents/claudeParse.ts +193 -0
- package/server/agents/codexAdapter.test.ts +125 -0
- package/server/agents/codexAdapter.ts +198 -0
- package/server/agents/codexParse.test.ts +58 -0
- package/server/agents/codexParse.ts +126 -0
- package/server/agents/detect.test.ts +65 -0
- package/server/agents/detect.ts +43 -0
- package/server/agents/gitScope.test.ts +129 -0
- package/server/agents/gitScope.ts +110 -0
- package/server/agents/modelRejectionHint.test.ts +30 -0
- package/server/agents/modelRejectionHint.ts +14 -0
- package/server/agents/prompt.test.ts +613 -0
- package/server/agents/prompt.ts +527 -0
- package/server/agents/runManager.test.ts +251 -0
- package/server/agents/runManager.ts +286 -0
- package/server/agents/types.ts +17 -0
- package/server/apiMount.test.ts +591 -0
- package/server/apiStore.test.ts +324 -0
- package/server/apiStore.ts +241 -0
- package/server/auth/enforce.test.ts +61 -0
- package/server/auth/enforce.ts +6 -0
- package/server/auth/types.ts +5 -0
- package/server/auth/verifiers.test.ts +37 -0
- package/server/auth/verifiers.ts +9 -0
- package/server/authAttach.test.ts +175 -0
- package/server/authAttach.ts +99 -0
- package/server/cli.ts +847 -0
- package/server/cliAuth.test.ts +229 -0
- package/server/cliCreate.test.ts +131 -0
- package/server/cliDoctor.test.ts +326 -0
- package/server/cliTest.test.ts +735 -0
- package/server/client.ts +314 -0
- package/server/demoProject.test.ts +29 -0
- package/server/diagnosticsRoute.test.ts +169 -0
- package/server/environments.test.ts +620 -0
- package/server/environments.ts +603 -0
- package/server/environmentsRoute.test.ts +385 -0
- package/server/errorWorkflow.test.ts +205 -0
- package/server/flowStore.test.ts +84 -0
- package/server/flowStore.ts +125 -0
- package/server/httpOperations.test.ts +377 -0
- package/server/httpOperations.ts +96 -0
- package/server/index.ts +1111 -0
- package/server/infrastructure.test.ts +154 -0
- package/server/infrastructure.ts +161 -0
- package/server/infrastructureRoute.test.ts +105 -0
- package/server/login.test.ts +153 -0
- package/server/login.ts +67 -0
- package/server/mcp.ts +278 -0
- package/server/migrateFlows.test.ts +55 -0
- package/server/migrateFlows.ts +23 -0
- package/server/mockHandler.test.ts +138 -0
- package/server/mockHandler.ts +52 -0
- package/server/mockRun.test.ts +367 -0
- package/server/mockServe.test.ts +467 -0
- package/server/nodesPayload.test.ts +29 -0
- package/server/nodesPayload.ts +26 -0
- package/server/normalizeFlow.test.ts +80 -0
- package/server/normalizeFlow.ts +59 -0
- package/server/openBrowser.test.ts +20 -0
- package/server/openBrowser.ts +22 -0
- package/server/operationResult.test.ts +67 -0
- package/server/operationResult.ts +27 -0
- package/server/pathGuard.test.ts +19 -0
- package/server/pathGuard.ts +12 -0
- package/server/pathSafety.test.ts +38 -0
- package/server/pathSafety.ts +33 -0
- package/server/projectConfig.test.ts +67 -0
- package/server/projectConfig.ts +101 -0
- package/server/projectMode.test.ts +106 -0
- package/server/projectMode.ts +59 -0
- package/server/redact.test.ts +88 -0
- package/server/redact.ts +35 -0
- package/server/redactEgress.test.ts +281 -0
- package/server/runRegistry.ts +270 -0
- package/server/runsAuth.test.ts +119 -0
- package/server/runtime/RuntimeAdapter.ts +24 -0
- package/server/runtime/expressAdapter.test.ts +38 -0
- package/server/runtime/expressAdapter.ts +70 -0
- package/server/scenarioTest.test.ts +148 -0
- package/server/scenarioTest.ts +88 -0
- package/server/setupStatus.test.ts +127 -0
- package/server/subflowRunner.ts +104 -0
- package/server/testRunner.ts +216 -0
- package/server/tsconfig.json +20 -0
- package/server/workflowTestRoute.test.ts +297 -0
- package/src/App.tsx +233 -0
- package/src/components/AgentConsole.tsx +461 -0
- package/src/components/BranchRulesEditor.tsx +156 -0
- package/src/components/CreateModal.tsx +275 -0
- package/src/components/Dock.tsx +487 -0
- package/src/components/EnvironmentDialog.tsx +569 -0
- package/src/components/EnvironmentPicker.tsx +357 -0
- package/src/components/EnvironmentsDialog.tsx +144 -0
- package/src/components/ExecutionPager.tsx +67 -0
- package/src/components/InfraPanel.test.tsx +103 -0
- package/src/components/InfraPanel.tsx +171 -0
- package/src/components/Inspector.tsx +1182 -0
- package/src/components/Json.tsx +50 -0
- package/src/components/JsonModal.tsx +52 -0
- package/src/components/LogMessage.tsx +61 -0
- package/src/components/NodeRunModal.tsx +213 -0
- package/src/components/RunLogPanel.tsx +397 -0
- package/src/components/RunbookView.tsx +1085 -0
- package/src/components/Scenarios.tsx +476 -0
- package/src/components/SettingsDialog.tsx +229 -0
- package/src/components/Sidebar.tsx +436 -0
- package/src/components/StatusBar.tsx +302 -0
- package/src/components/Toolbar.tsx +663 -0
- package/src/components/WelcomeDialog.test.tsx +107 -0
- package/src/components/WelcomeDialog.tsx +412 -0
- package/src/components/agentMarkdown.test.tsx +91 -0
- package/src/components/agentMarkdown.tsx +158 -0
- package/src/components/ui/badge.tsx +31 -0
- package/src/components/ui/button.tsx +46 -0
- package/src/components/ui/combobox.tsx +114 -0
- package/src/components/ui/command.tsx +82 -0
- package/src/components/ui/dialog.tsx +73 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/popover.tsx +32 -0
- package/src/components/ui/resizable.tsx +44 -0
- package/src/components/ui/tabs.tsx +36 -0
- package/src/config.ts +4 -0
- package/src/engine/authVerify.ts +81 -0
- package/src/engine/diagnostics.test.ts +437 -0
- package/src/engine/diagnostics.ts +211 -0
- package/src/engine/executor.test.ts +1339 -0
- package/src/engine/executor.ts +840 -0
- package/src/engine/httpError.test.ts +12 -0
- package/src/engine/httpError.ts +16 -0
- package/src/engine/index.ts +11 -0
- package/src/engine/output.ts +21 -0
- package/src/engine/publish.test.ts +176 -0
- package/src/engine/publish.ts +127 -0
- package/src/engine/registry.test.ts +64 -0
- package/src/engine/registry.ts +80 -0
- package/src/engine/scenarios.test.ts +55 -0
- package/src/engine/scenarios.ts +29 -0
- package/src/engine/schemaCheck.test.ts +21 -0
- package/src/engine/schemaCheck.ts +35 -0
- package/src/engine/trace.test.ts +19 -0
- package/src/engine/trace.ts +23 -0
- package/src/engine/types.test.ts +22 -0
- package/src/engine/types.ts +316 -0
- package/src/engine/validation.test.ts +314 -0
- package/src/engine/validation.ts +354 -0
- package/src/flows/anomaly-flows.test.ts +81 -0
- package/src/flows/anomaly-flows.ts +202 -0
- package/src/flows/city-sweep-flow.ts +131 -0
- package/src/flows/flows.test.ts +30 -0
- package/src/flows/login-flow.ts +150 -0
- package/src/flows/pradar-flows.test.ts +204 -0
- package/src/flows/pradar-flows.ts +419 -0
- package/src/flows/weather-flow.ts +154 -0
- package/src/globals.css +147 -0
- package/src/lib/pathParams.test.ts +24 -0
- package/src/lib/pathParams.ts +6 -0
- package/src/lib/registerLens.test.ts +79 -0
- package/src/lib/registerLens.ts +59 -0
- package/src/lib/runbookModel.test.ts +61 -0
- package/src/lib/runbookModel.ts +290 -0
- package/src/lib/runbookProjection.test.ts +417 -0
- package/src/lib/runbookProjection.ts +254 -0
- package/src/lib/useTailAnchor.ts +53 -0
- package/src/lib/utils.ts +7 -0
- package/src/main.tsx +13 -0
- package/src/nodes/anomaly.test.ts +98 -0
- package/src/nodes/anomaly.ts +637 -0
- package/src/nodes/effects-scan.test.ts +44 -0
- package/src/nodes/flow-control.test.ts +280 -0
- package/src/nodes/flow-control.ts +305 -0
- package/src/nodes/index.ts +22 -0
- package/src/nodes/login.test.ts +81 -0
- package/src/nodes/login.ts +186 -0
- package/src/nodes/loops.ts +69 -0
- package/src/nodes/pradar/index.ts +11 -0
- package/src/nodes/pradar/logic.test.ts +288 -0
- package/src/nodes/pradar/logic.ts +582 -0
- package/src/nodes/pradar/nodes.test.ts +146 -0
- package/src/nodes/pradar/nodes.ts +955 -0
- package/src/nodes/requireAuth.test.ts +75 -0
- package/src/nodes/requireAuth.ts +55 -0
- package/src/nodes/response.test.ts +33 -0
- package/src/nodes/response.ts +39 -0
- package/src/nodes/traceDetail.test.ts +39 -0
- package/src/nodes/weather.test.ts +65 -0
- package/src/nodes/weather.ts +306 -0
- package/src/store/agentClient.test.ts +103 -0
- package/src/store/agentClient.ts +131 -0
- package/src/store/apiTree.test.ts +17 -0
- package/src/store/apiTree.ts +73 -0
- package/src/store/builderStore.test.ts +1234 -0
- package/src/store/builderStore.ts +1879 -0
- package/src/store/infraClient.ts +63 -0
- package/src/store/nodeMeta.test.ts +24 -0
- package/src/store/nodeMeta.ts +21 -0
- package/src/store/persistence.test.ts +15 -0
- package/src/store/persistence.ts +54 -0
- package/src/store/serverRunner.ts +381 -0
- package/src/store/setupClient.ts +57 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/studio-dist/assets/index-DNJwW-hM.css +1 -0
- package/studio-dist/assets/index-O26dKRjW.js +65 -0
- package/studio-dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/studio-dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/studio-dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/studio-dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/studio-dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/studio-dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/studio-dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/studio-dist/favicon.svg +1 -0
- package/studio-dist/icons.svg +24 -0
- package/studio-dist/index.html +14 -0
- package/templates/skills/emberflow-basics/SKILL.md +316 -0
- package/templates/skills/emberflow-model-process/SKILL.md +209 -0
- package/templates/skills/emberflow-new-workflow/SKILL.md +211 -0
- package/templates/skills/emberflow-review-workflow/SKILL.md +148 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** Describes how the runner authenticates studio runs against an environment. */
|
|
6
|
+
export interface EnvAuth {
|
|
7
|
+
attach: { as: 'cookie' | 'header'; name: string; secretRef: string; prefix?: string };
|
|
8
|
+
login?: {
|
|
9
|
+
request: { method: string; url: string; headers?: Record<string, string>; bodyRef?: string };
|
|
10
|
+
capture:
|
|
11
|
+
| { from: 'set-cookie'; cookieName?: string }
|
|
12
|
+
| { from: 'json'; path: string }
|
|
13
|
+
| { from: 'header'; name: string };
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A named value-set the runner injects into a run. */
|
|
18
|
+
export interface EnvironmentDefinition {
|
|
19
|
+
/** Non-secret values (API base URLs, seed ids, flags) — visible in UI/logs. */
|
|
20
|
+
vars: Record<string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* Resolved secret map (KEY -> value) as seen by the runtime. Values live in
|
|
23
|
+
* `emberflow.secrets.json` (never in `emberflow.environments.json`, which
|
|
24
|
+
* carries only the list of key NAMES). A key that is declared but has no
|
|
25
|
+
* value in the secrets file resolves to '' so `requireSecret` still throws a
|
|
26
|
+
* helpful "missing secret" error. Masked everywhere: keys visible, values
|
|
27
|
+
* never shown.
|
|
28
|
+
*/
|
|
29
|
+
secrets: Record<string, string>;
|
|
30
|
+
/** Marks environments where mistakes are expensive (e.g. prod). Forces safe mode by default. */
|
|
31
|
+
protected?: boolean;
|
|
32
|
+
/** Optional studio-run auth injection config. Absent means no auth injection (backward compatible). */
|
|
33
|
+
auth?: EnvAuth;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EnvironmentsFile {
|
|
37
|
+
defaultEnvironment: string;
|
|
38
|
+
environments: Record<string, EnvironmentDefinition>;
|
|
39
|
+
/** True when the project deliberately configured environments (an
|
|
40
|
+
* emberflow.environments.json exists, or legacy secrets were found) —
|
|
41
|
+
* false when the loader synthesized the bare "local" fallback. Drives the
|
|
42
|
+
* default serving mode: an unconfigured project boots in mock. */
|
|
43
|
+
configured: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The on-disk shape of `emberflow.environments.json`. Structure only: `secrets`
|
|
48
|
+
* is a list of key NAMES in the new format (a value-map is still accepted for
|
|
49
|
+
* one migration cycle). Distinct from `EnvironmentDefinition`, whose `secrets`
|
|
50
|
+
* is the RESOLVED value map the runtime consumes.
|
|
51
|
+
*/
|
|
52
|
+
interface RawEnvironmentDefinition {
|
|
53
|
+
vars?: Record<string, string>;
|
|
54
|
+
secrets?: string[] | Record<string, string>;
|
|
55
|
+
protected?: boolean;
|
|
56
|
+
auth?: EnvAuth;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RawEnvironmentsFile {
|
|
60
|
+
defaultEnvironment: string;
|
|
61
|
+
environments: Record<string, RawEnvironmentDefinition>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ENVIRONMENTS_FILENAME = 'emberflow.environments.json';
|
|
65
|
+
const SECRETS_FILENAME = 'emberflow.secrets.json';
|
|
66
|
+
|
|
67
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
72
|
+
if (!isPlainObject(value)) return false;
|
|
73
|
+
return Object.values(value).every((v) => typeof v === 'string');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isStringArray(value: unknown): value is string[] {
|
|
77
|
+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A nested `{ envName: { KEY: value } }` secrets-file map (new shape). */
|
|
81
|
+
function isNestedSecretsMap(value: unknown): value is Record<string, Record<string, string>> {
|
|
82
|
+
if (!isPlainObject(value)) return false;
|
|
83
|
+
return Object.values(value).every((v) => isStringRecord(v));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validates an `EnvAuth` value's shape. Returns an error message (without any
|
|
88
|
+
* `environments.<name>.` prefix) naming the problem, or `null` if valid.
|
|
89
|
+
* Shared by `validateShape` (file load) and `setEnvironmentAuth` (writer) so
|
|
90
|
+
* the rules can't drift between the two call sites.
|
|
91
|
+
*/
|
|
92
|
+
function validateAuthShape(auth: unknown): string | null {
|
|
93
|
+
if (!isPlainObject(auth)) {
|
|
94
|
+
return 'auth must be an object';
|
|
95
|
+
}
|
|
96
|
+
const attach = auth.attach;
|
|
97
|
+
if (!isPlainObject(attach)) {
|
|
98
|
+
return 'auth.attach must be an object';
|
|
99
|
+
}
|
|
100
|
+
if (attach.as !== 'cookie' && attach.as !== 'header') {
|
|
101
|
+
return 'auth.attach.as must be "cookie" or "header"';
|
|
102
|
+
}
|
|
103
|
+
if (typeof attach.name !== 'string') {
|
|
104
|
+
return 'auth.attach.name must be a string';
|
|
105
|
+
}
|
|
106
|
+
if (typeof attach.secretRef !== 'string') {
|
|
107
|
+
return 'auth.attach.secretRef must be a string';
|
|
108
|
+
}
|
|
109
|
+
if (attach.prefix !== undefined && typeof attach.prefix !== 'string') {
|
|
110
|
+
return 'auth.attach.prefix must be a string';
|
|
111
|
+
}
|
|
112
|
+
if (auth.login !== undefined && !isPlainObject(auth.login)) {
|
|
113
|
+
return 'auth.login must be an object';
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Throws with a message pinpointing what's wrong in the file, for a clear boot-time failure. */
|
|
119
|
+
function validateShape(parsed: unknown): asserts parsed is RawEnvironmentsFile {
|
|
120
|
+
const prefix = `${ENVIRONMENTS_FILENAME} is malformed`;
|
|
121
|
+
if (!isPlainObject(parsed)) {
|
|
122
|
+
throw new Error(`${prefix}: expected a JSON object at the top level`);
|
|
123
|
+
}
|
|
124
|
+
if (typeof parsed.defaultEnvironment !== 'string' || parsed.defaultEnvironment.length === 0) {
|
|
125
|
+
throw new Error(`${prefix}: "defaultEnvironment" must be a non-empty string`);
|
|
126
|
+
}
|
|
127
|
+
if (!isPlainObject(parsed.environments)) {
|
|
128
|
+
throw new Error(`${prefix}: "environments" must be an object of name -> environment`);
|
|
129
|
+
}
|
|
130
|
+
const names = Object.keys(parsed.environments);
|
|
131
|
+
if (names.length === 0) {
|
|
132
|
+
throw new Error(`${prefix}: "environments" must define at least one environment`);
|
|
133
|
+
}
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
const env = parsed.environments[name];
|
|
136
|
+
if (!isPlainObject(env)) {
|
|
137
|
+
throw new Error(`${prefix}: environments.${name} must be an object`);
|
|
138
|
+
}
|
|
139
|
+
if (env.vars !== undefined && !isStringRecord(env.vars)) {
|
|
140
|
+
throw new Error(`${prefix}: environments.${name}.vars must be an object of string -> string`);
|
|
141
|
+
}
|
|
142
|
+
// `secrets` is a LIST of key names (new, structure-only shape). The old
|
|
143
|
+
// map-of-values shape is still accepted here so a pre-migration file loads;
|
|
144
|
+
// loadEnvironments migrates its values out on the next load.
|
|
145
|
+
if (env.secrets !== undefined && !isStringArray(env.secrets) && !isStringRecord(env.secrets)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`${prefix}: environments.${name}.secrets must be an array of key names or an object of string -> string`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (env.protected !== undefined && typeof env.protected !== 'boolean') {
|
|
151
|
+
throw new Error(`${prefix}: environments.${name}.protected must be a boolean`);
|
|
152
|
+
}
|
|
153
|
+
if (env.auth !== undefined) {
|
|
154
|
+
const authError = validateAuthShape(env.auth);
|
|
155
|
+
if (authError !== null) {
|
|
156
|
+
throw new Error(`${prefix}: environments.${name}.${authError}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!(parsed.defaultEnvironment in parsed.environments)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${prefix}: defaultEnvironment "${parsed.defaultEnvironment}" is not one of the defined environments (${names.join(', ')})`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Secret VALUES live in emberflow.secrets.json, structure-only in
|
|
169
|
+
// environments.json. Everything below implements that split: reading the
|
|
170
|
+
// secrets file (new nested shape + legacy flat), $ENV indirection, file
|
|
171
|
+
// hardening warnings, and the one-time auto-migration of old inline values.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
const secretsFileMode = 0o600;
|
|
175
|
+
|
|
176
|
+
/** One-shot per-process warning dedupe so repeated loads don't spam the log. */
|
|
177
|
+
const warnedOnce = new Set<string>();
|
|
178
|
+
function warnOnce(key: string, message: string): void {
|
|
179
|
+
if (warnedOnce.has(key)) return;
|
|
180
|
+
warnedOnce.add(key);
|
|
181
|
+
console.warn(message);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface SecretsFileContents {
|
|
185
|
+
/** Resolved-per-env raw values: { envName: { KEY: rawValue } }. */
|
|
186
|
+
byEnv: Record<string, Record<string, string>>;
|
|
187
|
+
/** True when the file used the legacy flat `{ KEY: value }` shape. */
|
|
188
|
+
legacyFlat: boolean;
|
|
189
|
+
existed: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Reads `emberflow.secrets.json`. Accepts the new nested
|
|
194
|
+
* `{ envName: { KEY: value } }` shape and the legacy flat `{ KEY: value }`
|
|
195
|
+
* shape (mapped to env "local", preserving the historical synthesized-env
|
|
196
|
+
* behavior). Missing or invalid file → empty. Values may be `$ENV:VAR`
|
|
197
|
+
* indirections; they are resolved at merge time, not here.
|
|
198
|
+
*/
|
|
199
|
+
function readSecretsFile(secretsPath: string): SecretsFileContents {
|
|
200
|
+
if (!existsSync(secretsPath)) return { byEnv: {}, legacyFlat: false, existed: false };
|
|
201
|
+
let parsed: unknown;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(readFileSync(secretsPath, 'utf8'));
|
|
204
|
+
} catch {
|
|
205
|
+
return { byEnv: {}, legacyFlat: false, existed: true };
|
|
206
|
+
}
|
|
207
|
+
if (isNestedSecretsMap(parsed)) {
|
|
208
|
+
// Empty {} is ambiguous but harmless as an empty nested map.
|
|
209
|
+
return { byEnv: parsed, legacyFlat: false, existed: true };
|
|
210
|
+
}
|
|
211
|
+
if (isStringRecord(parsed)) {
|
|
212
|
+
return { byEnv: { local: parsed }, legacyFlat: true, existed: true };
|
|
213
|
+
}
|
|
214
|
+
return { byEnv: {}, legacyFlat: false, existed: true };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Writes the nested secrets map and locks the file down to 0600. */
|
|
218
|
+
function writeSecretsFile(secretsPath: string, byEnv: Record<string, Record<string, string>>): void {
|
|
219
|
+
writeFileSync(secretsPath, JSON.stringify(byEnv, null, 2) + '\n');
|
|
220
|
+
try {
|
|
221
|
+
chmodSync(secretsPath, secretsFileMode);
|
|
222
|
+
} catch {
|
|
223
|
+
// Best-effort on platforms/filesystems without POSIX modes.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolves a stored secret value. A value of the form `$ENV:VAR_NAME`
|
|
229
|
+
* indirects to `process.env.VAR_NAME`; a missing/empty env var resolves to ''
|
|
230
|
+
* and warns (once) naming the variable. All other values pass through.
|
|
231
|
+
*/
|
|
232
|
+
function resolveSecretValue(raw: string): string {
|
|
233
|
+
const match = /^\$ENV:(.+)$/.exec(raw);
|
|
234
|
+
if (!match) return raw;
|
|
235
|
+
const varName = match[1];
|
|
236
|
+
const fromEnv = process.env[varName];
|
|
237
|
+
if (fromEnv === undefined || fromEnv === '') {
|
|
238
|
+
warnOnce(
|
|
239
|
+
`envvar:${varName}`,
|
|
240
|
+
`[emberflow] secret indirection $ENV:${varName} is unset — resolving to empty. Set ${varName} in the environment.`,
|
|
241
|
+
);
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
return fromEnv;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** True when the file is tracked by git. Safe/cheap; false when not a repo. */
|
|
248
|
+
function isTrackedByGit(cwd: string, relPath: string): boolean {
|
|
249
|
+
try {
|
|
250
|
+
execFileSync('git', ['ls-files', '--error-unmatch', relPath], {
|
|
251
|
+
cwd,
|
|
252
|
+
stdio: 'ignore',
|
|
253
|
+
});
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Warns (once per process) if the secrets file is group/other-readable or is
|
|
262
|
+
* tracked by git — both leak plaintext credentials.
|
|
263
|
+
*/
|
|
264
|
+
const hardeningChecked = new Set<string>();
|
|
265
|
+
function checkSecretsFileHardening(cwd: string, secretsPath: string): void {
|
|
266
|
+
if (!existsSync(secretsPath)) return;
|
|
267
|
+
// Runs once per path per process — loadEnvironments is called on every
|
|
268
|
+
// GET /environments and we don't want to spawn `git` on that hot path.
|
|
269
|
+
if (hardeningChecked.has(secretsPath)) return;
|
|
270
|
+
hardeningChecked.add(secretsPath);
|
|
271
|
+
try {
|
|
272
|
+
const mode = statSync(secretsPath).mode & 0o777;
|
|
273
|
+
if ((mode & 0o077) !== 0) {
|
|
274
|
+
// Loose perms on a hand-written secrets file are fixable in place — do
|
|
275
|
+
// it and say so, rather than nagging on every load with no remediation.
|
|
276
|
+
try {
|
|
277
|
+
chmodSync(secretsPath, secretsFileMode);
|
|
278
|
+
console.info(`[emberflow] tightened ${SECRETS_FILENAME} to 0600`);
|
|
279
|
+
} catch {
|
|
280
|
+
// Couldn't chmod (e.g. read-only filesystem) — fall back to warning
|
|
281
|
+
// since we weren't able to fix it ourselves.
|
|
282
|
+
warnOnce(
|
|
283
|
+
`mode:${secretsPath}`,
|
|
284
|
+
`[emberflow] SECURITY: ${SECRETS_FILENAME} is readable by group/other (mode 0${mode.toString(8)}); ` +
|
|
285
|
+
`it holds plaintext secrets. Run: chmod 600 ${SECRETS_FILENAME}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
// stat failure is non-fatal
|
|
291
|
+
}
|
|
292
|
+
if (isTrackedByGit(cwd, SECRETS_FILENAME)) {
|
|
293
|
+
warnOnce(
|
|
294
|
+
`git:${secretsPath}`,
|
|
295
|
+
`[emberflow] SECURITY: ${SECRETS_FILENAME} is tracked by git and holds plaintext secrets. ` +
|
|
296
|
+
`Remove it from the repo: git rm --cached ${SECRETS_FILENAME} (it should stay gitignored).`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** True when any environment still carries inline non-empty secret VALUES. */
|
|
302
|
+
function needsMigration(parsed: RawEnvironmentsFile): boolean {
|
|
303
|
+
for (const env of Object.values(parsed.environments)) {
|
|
304
|
+
const s = env.secrets;
|
|
305
|
+
if (s !== undefined && !Array.isArray(s) && Object.values(s).some((v) => v !== '')) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* One-time migration: moves inline secret VALUES out of environments.json into
|
|
314
|
+
* emberflow.secrets.json (0600), rewrites environments.json so each env's
|
|
315
|
+
* `secrets` is a list of key NAMES, and keeps a `.bak` of the original
|
|
316
|
+
* environments.json on first migration. Idempotent — after it runs there are
|
|
317
|
+
* no inline values left, so `needsMigration` returns false on the next load.
|
|
318
|
+
*/
|
|
319
|
+
function migrateEnvironmentsFile(
|
|
320
|
+
environmentsPath: string,
|
|
321
|
+
secretsPath: string,
|
|
322
|
+
parsed: RawEnvironmentsFile,
|
|
323
|
+
): void {
|
|
324
|
+
const backupPath = `${environmentsPath}.bak`;
|
|
325
|
+
if (!existsSync(backupPath)) {
|
|
326
|
+
copyFileSync(environmentsPath, backupPath);
|
|
327
|
+
}
|
|
328
|
+
const { byEnv } = readSecretsFile(secretsPath);
|
|
329
|
+
for (const [name, env] of Object.entries(parsed.environments)) {
|
|
330
|
+
const s = env.secrets;
|
|
331
|
+
if (s === undefined || Array.isArray(s)) continue;
|
|
332
|
+
const bucket = { ...(byEnv[name] ?? {}) };
|
|
333
|
+
for (const [key, value] of Object.entries(s)) {
|
|
334
|
+
// environments.json is the authoritative current source — its values win.
|
|
335
|
+
if (value !== '') bucket[key] = value;
|
|
336
|
+
}
|
|
337
|
+
byEnv[name] = bucket;
|
|
338
|
+
env.secrets = Object.keys(s);
|
|
339
|
+
}
|
|
340
|
+
writeSecretsFile(secretsPath, byEnv);
|
|
341
|
+
writeFileSync(environmentsPath, JSON.stringify(parsed, null, 2) + '\n');
|
|
342
|
+
console.log(
|
|
343
|
+
`[emberflow] Migrated inline secret values from ${ENVIRONMENTS_FILENAME} into ${SECRETS_FILENAME} (0600). ` +
|
|
344
|
+
`${ENVIRONMENTS_FILENAME} now holds secret key NAMES only; a .bak of the original was kept.`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Merges an env's declared key names with the values present in the secrets
|
|
350
|
+
* file: resolved map = names ∪ secrets-file keys, value from the secrets file
|
|
351
|
+
* (with $ENV resolution) or '' when absent so requireSecret still throws.
|
|
352
|
+
*/
|
|
353
|
+
function resolveEnvironment(
|
|
354
|
+
env: RawEnvironmentDefinition,
|
|
355
|
+
fileSecrets: Record<string, string> | undefined,
|
|
356
|
+
): EnvironmentDefinition {
|
|
357
|
+
const names = Array.isArray(env.secrets) ? env.secrets : Object.keys(env.secrets ?? {});
|
|
358
|
+
const keys = new Set<string>([...names, ...Object.keys(fileSecrets ?? {})]);
|
|
359
|
+
const secrets: Record<string, string> = {};
|
|
360
|
+
for (const key of keys) {
|
|
361
|
+
const raw = fileSecrets?.[key];
|
|
362
|
+
secrets[key] = raw === undefined ? '' : resolveSecretValue(raw);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
vars: env.vars ?? {},
|
|
366
|
+
secrets,
|
|
367
|
+
...(env.protected !== undefined ? { protected: env.protected } : {}),
|
|
368
|
+
...(env.auth !== undefined ? { auth: env.auth } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface RunSafetyRequest {
|
|
373
|
+
safeMode?: boolean;
|
|
374
|
+
confirm?: string;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export type RunSafetyResolution =
|
|
378
|
+
| { ok: true; safeMode: boolean }
|
|
379
|
+
| { ok: false; error: string };
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Resolves the effective `safeMode` for a run against a given environment.
|
|
383
|
+
*
|
|
384
|
+
* - Omitted `safeMode` defaults to the environment's protection: `true` when
|
|
385
|
+
* `protected`, `false` otherwise.
|
|
386
|
+
* - Explicitly requesting `safeMode: false` on a protected environment is
|
|
387
|
+
* refused unless `confirm` matches the environment name exactly.
|
|
388
|
+
* - Any other explicit request (including `safeMode: true` on any env, or
|
|
389
|
+
* `safeMode: false` on a non-protected env) is honored as-is.
|
|
390
|
+
*/
|
|
391
|
+
export function resolveRunSafety(
|
|
392
|
+
envName: string,
|
|
393
|
+
env: EnvironmentDefinition,
|
|
394
|
+
request: RunSafetyRequest,
|
|
395
|
+
): RunSafetyResolution {
|
|
396
|
+
const isProtected = !!env.protected;
|
|
397
|
+
if (request.safeMode === undefined) {
|
|
398
|
+
return { ok: true, safeMode: isProtected };
|
|
399
|
+
}
|
|
400
|
+
if (request.safeMode === false && isProtected && request.confirm !== envName) {
|
|
401
|
+
return { ok: false, error: `unsafe run on protected environment '${envName}' requires confirm` };
|
|
402
|
+
}
|
|
403
|
+
return { ok: true, safeMode: request.safeMode };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Loads `emberflow.environments.json` from `cwd`.
|
|
408
|
+
*
|
|
409
|
+
* - File present: parsed and validated; malformed files throw with a clear
|
|
410
|
+
* message (meant to fail the boot loudly rather than run with bad config).
|
|
411
|
+
* - File absent but `emberflow.secrets.json` present: synthesizes a single
|
|
412
|
+
* `local` environment from it (logs a hint to migrate) so existing setups
|
|
413
|
+
* keep working.
|
|
414
|
+
* - Neither file present: an empty `local` environment (matches the previous
|
|
415
|
+
* loadSecrets() behavior of defaulting to {} when nothing is configured).
|
|
416
|
+
*
|
|
417
|
+
* Secret VALUES are read from `emberflow.secrets.json` and merged onto the key
|
|
418
|
+
* NAMES declared in environments.json. A first load of an old-format file
|
|
419
|
+
* (inline values) auto-migrates the values out. See the helpers above.
|
|
420
|
+
*/
|
|
421
|
+
function parseEnvironmentsFile(environmentsPath: string): RawEnvironmentsFile {
|
|
422
|
+
const raw = readFileSync(environmentsPath, 'utf8');
|
|
423
|
+
let parsed: unknown;
|
|
424
|
+
try {
|
|
425
|
+
parsed = JSON.parse(raw);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`${ENVIRONMENTS_FILENAME} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
validateShape(parsed);
|
|
432
|
+
return parsed;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function loadEnvironments(cwd: string): EnvironmentsFile {
|
|
436
|
+
const environmentsPath = resolve(cwd, ENVIRONMENTS_FILENAME);
|
|
437
|
+
const secretsPath = resolve(cwd, SECRETS_FILENAME);
|
|
438
|
+
|
|
439
|
+
if (existsSync(environmentsPath)) {
|
|
440
|
+
let parsed = parseEnvironmentsFile(environmentsPath);
|
|
441
|
+
if (needsMigration(parsed)) {
|
|
442
|
+
migrateEnvironmentsFile(environmentsPath, secretsPath, parsed);
|
|
443
|
+
// Re-read so the resolved view reflects the rewritten name-list file.
|
|
444
|
+
parsed = parseEnvironmentsFile(environmentsPath);
|
|
445
|
+
}
|
|
446
|
+
checkSecretsFileHardening(cwd, secretsPath);
|
|
447
|
+
const { byEnv } = readSecretsFile(secretsPath);
|
|
448
|
+
const environments: Record<string, EnvironmentDefinition> = {};
|
|
449
|
+
for (const [name, env] of Object.entries(parsed.environments)) {
|
|
450
|
+
environments[name] = resolveEnvironment(env, byEnv[name]);
|
|
451
|
+
}
|
|
452
|
+
return { defaultEnvironment: parsed.defaultEnvironment, environments, configured: true };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (existsSync(secretsPath)) {
|
|
456
|
+
console.log(
|
|
457
|
+
`[emberflow] No ${ENVIRONMENTS_FILENAME} found; synthesizing a "local" environment from ${SECRETS_FILENAME}. ` +
|
|
458
|
+
`Create ${ENVIRONMENTS_FILENAME} to configure multiple environments (see emberflow.environments.example.json).`,
|
|
459
|
+
);
|
|
460
|
+
checkSecretsFileHardening(cwd, secretsPath);
|
|
461
|
+
const { byEnv } = readSecretsFile(secretsPath);
|
|
462
|
+
const names = Object.keys(byEnv);
|
|
463
|
+
const environments: Record<string, EnvironmentDefinition> = {};
|
|
464
|
+
for (const name of names) {
|
|
465
|
+
environments[name] = resolveEnvironment({ secrets: Object.keys(byEnv[name]) }, byEnv[name]);
|
|
466
|
+
}
|
|
467
|
+
// Preserve the historical single synthesized-env behavior: always expose a
|
|
468
|
+
// "local" env even when the file was empty.
|
|
469
|
+
if (!environments.local) {
|
|
470
|
+
environments.local = { vars: {}, secrets: {} };
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
defaultEnvironment: environments.local ? 'local' : names[0],
|
|
474
|
+
environments,
|
|
475
|
+
configured: true,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
defaultEnvironment: 'local',
|
|
481
|
+
environments: { local: { vars: {}, secrets: {} } },
|
|
482
|
+
configured: false,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Serializes all environment-file read-modify-write cycles through a single
|
|
487
|
+
// promise chain so concurrent setEnvironmentSecret calls can't clobber each
|
|
488
|
+
// other (last-write-wins on a sync read-modify-write would drop a secret).
|
|
489
|
+
let writeQueue: Promise<unknown> = Promise.resolve();
|
|
490
|
+
function enqueue<T>(fn: () => T): Promise<T> {
|
|
491
|
+
const p = writeQueue.then(fn);
|
|
492
|
+
writeQueue = p.catch(() => {});
|
|
493
|
+
return p;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Current secret key NAMES for an env, from either on-disk shape. */
|
|
497
|
+
function secretNames(env: RawEnvironmentDefinition): string[] {
|
|
498
|
+
return Array.isArray(env.secrets) ? [...env.secrets] : Object.keys(env.secrets ?? {});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Folds any pre-migration inline map VALUES into `bucket` so converting an
|
|
503
|
+
* env's `secrets` to a name list never drops a sibling key's value. File
|
|
504
|
+
* values already present win (they are the current source of truth).
|
|
505
|
+
*/
|
|
506
|
+
function foldInlineValues(env: RawEnvironmentDefinition, bucket: Record<string, string>): void {
|
|
507
|
+
if (Array.isArray(env.secrets) || !env.secrets) return;
|
|
508
|
+
for (const [k, v] of Object.entries(env.secrets)) {
|
|
509
|
+
if (v !== '' && !(k in bucket)) bucket[k] = v;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Sets a single secret's VALUE in `emberflow.secrets.json` (0600) and ensures
|
|
515
|
+
* its key NAME is listed on the environment in `emberflow.environments.json`,
|
|
516
|
+
* both at `cwd`. The value never touches environments.json. Throws if the
|
|
517
|
+
* environments file is missing/malformed or the environment name is unknown.
|
|
518
|
+
* Concurrent calls are serialized so two in-flight writes never clobber each other.
|
|
519
|
+
*/
|
|
520
|
+
export function setEnvironmentSecret(cwd: string, envName: string, key: string, value: string): Promise<void> {
|
|
521
|
+
return enqueue(() => {
|
|
522
|
+
const environmentsPath = resolve(cwd, ENVIRONMENTS_FILENAME);
|
|
523
|
+
const secretsPath = resolve(cwd, SECRETS_FILENAME);
|
|
524
|
+
const parsed = parseEnvironmentsFile(environmentsPath);
|
|
525
|
+
const env = parsed.environments[envName];
|
|
526
|
+
if (!env) {
|
|
527
|
+
throw new Error(`${ENVIRONMENTS_FILENAME}: unknown environment "${envName}"`);
|
|
528
|
+
}
|
|
529
|
+
// Value → secrets file (0600).
|
|
530
|
+
const { byEnv } = readSecretsFile(secretsPath);
|
|
531
|
+
const bucket = { ...(byEnv[envName] ?? {}) };
|
|
532
|
+
foldInlineValues(env, bucket);
|
|
533
|
+
bucket[key] = value;
|
|
534
|
+
byEnv[envName] = bucket;
|
|
535
|
+
writeSecretsFile(secretsPath, byEnv);
|
|
536
|
+
// Name → environments.json (list form).
|
|
537
|
+
const names = secretNames(env);
|
|
538
|
+
if (!names.includes(key)) names.push(key);
|
|
539
|
+
env.secrets = names;
|
|
540
|
+
writeFileSync(environmentsPath, JSON.stringify(parsed, null, 2) + '\n');
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Sets (or, when `auth` is `null`, clears) an environment's `auth` config in
|
|
546
|
+
* `emberflow.environments.json` at `cwd` and persists the change. Validates
|
|
547
|
+
* the auth shape before writing. Throws if the environments file is
|
|
548
|
+
* missing/malformed, the environment name is unknown, or `auth` is invalid.
|
|
549
|
+
* Preserves the environment's other fields (secrets, vars, protected,
|
|
550
|
+
* unknown fields). Shares the write queue with `setEnvironmentSecret` so
|
|
551
|
+
* concurrent writers can't clobber each other.
|
|
552
|
+
*/
|
|
553
|
+
export function setEnvironmentAuth(cwd: string, envName: string, auth: EnvAuth | null): Promise<void> {
|
|
554
|
+
return enqueue(() => {
|
|
555
|
+
if (auth !== null) {
|
|
556
|
+
const authError = validateAuthShape(auth);
|
|
557
|
+
if (authError !== null) {
|
|
558
|
+
throw new Error(`${ENVIRONMENTS_FILENAME}: environments.${envName}.${authError}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const environmentsPath = resolve(cwd, ENVIRONMENTS_FILENAME);
|
|
562
|
+
const parsed = parseEnvironmentsFile(environmentsPath);
|
|
563
|
+
const env = parsed.environments[envName];
|
|
564
|
+
if (!env) {
|
|
565
|
+
throw new Error(`${ENVIRONMENTS_FILENAME}: unknown environment "${envName}"`);
|
|
566
|
+
}
|
|
567
|
+
if (auth === null) {
|
|
568
|
+
delete env.auth;
|
|
569
|
+
} else {
|
|
570
|
+
env.auth = auth;
|
|
571
|
+
}
|
|
572
|
+
writeFileSync(environmentsPath, JSON.stringify(parsed, null, 2) + '\n');
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Removes a single secret from an environment: drops its key NAME from
|
|
578
|
+
* `emberflow.environments.json` and its VALUE from `emberflow.secrets.json`,
|
|
579
|
+
* both at `cwd`. Missing key resolves without error. Throws if the environments
|
|
580
|
+
* file is missing/malformed or the environment name is unknown. Shares the
|
|
581
|
+
* write queue with the other writers.
|
|
582
|
+
*/
|
|
583
|
+
export function deleteEnvironmentSecret(cwd: string, envName: string, key: string): Promise<void> {
|
|
584
|
+
return enqueue(() => {
|
|
585
|
+
const environmentsPath = resolve(cwd, ENVIRONMENTS_FILENAME);
|
|
586
|
+
const secretsPath = resolve(cwd, SECRETS_FILENAME);
|
|
587
|
+
const parsed = parseEnvironmentsFile(environmentsPath);
|
|
588
|
+
const env = parsed.environments[envName];
|
|
589
|
+
if (!env) {
|
|
590
|
+
throw new Error(`${ENVIRONMENTS_FILENAME}: unknown environment "${envName}"`);
|
|
591
|
+
}
|
|
592
|
+
// Value out of the secrets file (preserving sibling inline values).
|
|
593
|
+
const { byEnv } = readSecretsFile(secretsPath);
|
|
594
|
+
const bucket = { ...(byEnv[envName] ?? {}) };
|
|
595
|
+
foldInlineValues(env, bucket);
|
|
596
|
+
delete bucket[key];
|
|
597
|
+
byEnv[envName] = bucket;
|
|
598
|
+
writeSecretsFile(secretsPath, byEnv);
|
|
599
|
+
// Name out of environments.json (normalizing to list form).
|
|
600
|
+
env.secrets = secretNames(env).filter((n) => n !== key);
|
|
601
|
+
writeFileSync(environmentsPath, JSON.stringify(parsed, null, 2) + '\n');
|
|
602
|
+
});
|
|
603
|
+
}
|