@monster-orange/den 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +166 -2
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +104 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets strategy selection.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order for the backend:
|
|
5
|
+
* 1. DEN_SECRETS_PROVIDER env var (doppler | 1password | env)
|
|
6
|
+
* 2. `secrets.provider` in config/local/user.yml (per-engineer)
|
|
7
|
+
* 3. `secrets.provider` in config/den.yml (team default)
|
|
8
|
+
* 4. "doppler"
|
|
9
|
+
*
|
|
10
|
+
* Adding a backend = implement SecretsProvider and register it in FACTORIES.
|
|
11
|
+
*/
|
|
12
|
+
import { OP_ACCOUNT_DEFAULT, OP_CLI_TIMEOUT_MS } from "../constants.js";
|
|
13
|
+
import { readDenConfig, readUserConfig } from "../den-config.js";
|
|
14
|
+
import { DopplerProvider } from "./doppler.js";
|
|
15
|
+
import { EnvProvider } from "./env.js";
|
|
16
|
+
import { OnePasswordProvider } from "./onepassword.js";
|
|
17
|
+
import type {
|
|
18
|
+
SecretRef,
|
|
19
|
+
SecretsProvider,
|
|
20
|
+
SecretsProviderName,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
import { envKey } from "../product.js";
|
|
24
|
+
export * from "./types.js";
|
|
25
|
+
export { flatKeyFor, toEnvName, refPath } from "./keys.js";
|
|
26
|
+
export { DopplerProvider } from "./doppler.js";
|
|
27
|
+
export { EnvProvider } from "./env.js";
|
|
28
|
+
export { OnePasswordProvider } from "./onepassword.js";
|
|
29
|
+
|
|
30
|
+
export interface SecretsConfig {
|
|
31
|
+
provider?: SecretsProviderName;
|
|
32
|
+
/**
|
|
33
|
+
* When true, `den up` / `make web` refuse to start until the backend is
|
|
34
|
+
* ready. Default false: a missing backend is a warning, because each repo's
|
|
35
|
+
* own `make up` manages its own secrets and den only needs this backend
|
|
36
|
+
* for test logins and the saved sudo password.
|
|
37
|
+
*/
|
|
38
|
+
required?: boolean;
|
|
39
|
+
doppler?: { project?: string; config?: string };
|
|
40
|
+
onepassword?: { account?: string };
|
|
41
|
+
/** `"vault/item/field": FLAT_KEY` overrides for doppler/env. */
|
|
42
|
+
keys?: Record<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Where the engine's own named secrets live, overriding the built-in
|
|
45
|
+
* defaults field by field (see {@link namedSecretRef}). Names in use:
|
|
46
|
+
* `sudo` (the saved local-dev sudo password) and `journeys_agent_key`
|
|
47
|
+
* (the journeys pillar's agent API key).
|
|
48
|
+
*
|
|
49
|
+
* refs:
|
|
50
|
+
* journeys_agent_key: { vault: Shared, item: CI Keys }
|
|
51
|
+
*/
|
|
52
|
+
refs?: Record<string, Partial<SecretRef>>;
|
|
53
|
+
/**
|
|
54
|
+
* The per-engineer developer keys `make den-setup-1password` collects
|
|
55
|
+
* into the developer-keys item (see scripts/setup-1password.ts). Default:
|
|
56
|
+
* only the keys the engine itself uses.
|
|
57
|
+
*/
|
|
58
|
+
developer_keys?: DeveloperKeysConfig;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One developer key the setup script prompts for. */
|
|
62
|
+
export interface DeveloperKeyDef {
|
|
63
|
+
/** Field label in the item (= the env-var name). */
|
|
64
|
+
field: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
/** Where to create one. */
|
|
67
|
+
url?: string;
|
|
68
|
+
required?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DeveloperKeysConfig {
|
|
72
|
+
/**
|
|
73
|
+
* A dotenv file (relative to the den root, e.g. an app checkout's
|
|
74
|
+
* `.env`) whose values are offered as defaults.
|
|
75
|
+
*/
|
|
76
|
+
env_file?: string;
|
|
77
|
+
fields?: DeveloperKeyDef[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const SECRETS_PROVIDERS: readonly SecretsProviderName[] = [
|
|
81
|
+
"doppler",
|
|
82
|
+
"1password",
|
|
83
|
+
"env",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const FACTORIES: Record<
|
|
87
|
+
SecretsProviderName,
|
|
88
|
+
(c: SecretsConfig) => SecretsProvider
|
|
89
|
+
> = {
|
|
90
|
+
doppler: (c) => new DopplerProvider({ ...c.doppler, keys: c.keys }),
|
|
91
|
+
"1password": (c) =>
|
|
92
|
+
new OnePasswordProvider({
|
|
93
|
+
account: c.onepassword?.account ?? OP_ACCOUNT_DEFAULT,
|
|
94
|
+
timeoutMs: OP_CLI_TIMEOUT_MS,
|
|
95
|
+
}),
|
|
96
|
+
env: (c) => new EnvProvider(c.keys),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function isProviderName(v: unknown): v is SecretsProviderName {
|
|
100
|
+
return typeof v === "string" && (SECRETS_PROVIDERS as string[]).includes(v);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Effective secrets config (team file, then per-engineer override). */
|
|
104
|
+
export function readSecretsConfig(): SecretsConfig {
|
|
105
|
+
let team: SecretsConfig = {};
|
|
106
|
+
let user: SecretsConfig = {};
|
|
107
|
+
try {
|
|
108
|
+
team = readDenConfig().secrets ?? {};
|
|
109
|
+
} catch {
|
|
110
|
+
/* unreadable config → defaults */
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
user = readUserConfig().secrets ?? {};
|
|
114
|
+
} catch {
|
|
115
|
+
/* no user config */
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
...team,
|
|
119
|
+
...user,
|
|
120
|
+
doppler: { ...team.doppler, ...user.doppler },
|
|
121
|
+
onepassword: { ...team.onepassword, ...user.onepassword },
|
|
122
|
+
keys: { ...team.keys, ...user.keys },
|
|
123
|
+
refs: { ...team.refs, ...user.refs },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The engine-named secret `name`: `defaults` with any configured
|
|
129
|
+
* `secrets.refs.<name>` fields laid over them. The engine never hard-wires
|
|
130
|
+
* an org's vault or item names; the defaults are neutral placeholders.
|
|
131
|
+
*/
|
|
132
|
+
export function namedSecretRef(
|
|
133
|
+
name: string,
|
|
134
|
+
defaults: SecretRef,
|
|
135
|
+
cfg: SecretsConfig = readSecretsConfig(),
|
|
136
|
+
): SecretRef {
|
|
137
|
+
const override = cfg.refs?.[name] ?? {};
|
|
138
|
+
const pick = (v: unknown, d: string): string =>
|
|
139
|
+
typeof v === "string" && v.trim() ? v.trim() : d;
|
|
140
|
+
const ref: SecretRef = {
|
|
141
|
+
vault: pick(override.vault, defaults.vault),
|
|
142
|
+
item: pick(override.item, defaults.item),
|
|
143
|
+
field: pick(override.field, defaults.field),
|
|
144
|
+
};
|
|
145
|
+
const key = override.key ?? defaults.key;
|
|
146
|
+
if (key) ref.key = key;
|
|
147
|
+
return ref;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resolveProviderName(
|
|
151
|
+
cfg: SecretsConfig,
|
|
152
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
153
|
+
): SecretsProviderName {
|
|
154
|
+
const fromEnv = env.DEN_SECRETS_PROVIDER;
|
|
155
|
+
if (fromEnv) {
|
|
156
|
+
if (!isProviderName(fromEnv)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${envKey("SECRETS_PROVIDER")}="${fromEnv}" is not one of ${SECRETS_PROVIDERS.join(", ")}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return fromEnv;
|
|
162
|
+
}
|
|
163
|
+
if (cfg.provider !== undefined && !isProviderName(cfg.provider)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`secrets.provider "${String(cfg.provider)}" is not one of ${SECRETS_PROVIDERS.join(", ")}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return cfg.provider ?? "doppler";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createSecretsProvider(
|
|
172
|
+
name: SecretsProviderName,
|
|
173
|
+
cfg: SecretsConfig = {},
|
|
174
|
+
): SecretsProvider {
|
|
175
|
+
return FACTORIES[name](cfg);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let cached: SecretsProvider | null = null;
|
|
179
|
+
let cachedKey = "";
|
|
180
|
+
|
|
181
|
+
/** The configured backend. Re-resolves when DEN_SECRETS_PROVIDER changes. */
|
|
182
|
+
export function getSecretsProvider(): SecretsProvider {
|
|
183
|
+
const cfg = readSecretsConfig();
|
|
184
|
+
const name = resolveProviderName(cfg);
|
|
185
|
+
const key = `${name}|${JSON.stringify(cfg)}`;
|
|
186
|
+
if (!cached || key !== cachedKey) {
|
|
187
|
+
cached = createSecretsProvider(name, cfg);
|
|
188
|
+
cachedKey = key;
|
|
189
|
+
}
|
|
190
|
+
return cached;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Test hook. */
|
|
194
|
+
export function resetSecretsProviderCache(): void {
|
|
195
|
+
cached = null;
|
|
196
|
+
cachedKey = "";
|
|
197
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SecretRef } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Upper-snake a free-form label: "Test User (Auth0)" → "TEST_USER_AUTH0". */
|
|
4
|
+
export function toEnvName(label: string): string {
|
|
5
|
+
return label
|
|
6
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
7
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
8
|
+
.replace(/^_+|_+$/g, "")
|
|
9
|
+
.toUpperCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Canonical `vault/item/field` string used as the key of `secrets.keys`. */
|
|
13
|
+
export function refPath(ref: SecretRef): string {
|
|
14
|
+
return `${ref.vault}/${ref.item}/${ref.field}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the flat key a key/value backend should use for `ref`:
|
|
19
|
+
* 1. `ref.key` when the caller pinned one;
|
|
20
|
+
* 2. the `secrets.keys` override map (`"vault/item/field": KEY`);
|
|
21
|
+
* 3. the field itself when it already looks like an env var (API keys);
|
|
22
|
+
* 4. otherwise `ITEM_FIELD` upper-snaked.
|
|
23
|
+
*/
|
|
24
|
+
export function flatKeyFor(
|
|
25
|
+
ref: SecretRef,
|
|
26
|
+
overrides: Record<string, string> = {},
|
|
27
|
+
): string {
|
|
28
|
+
if (ref.key) return ref.key;
|
|
29
|
+
const mapped = overrides[refPath(ref)];
|
|
30
|
+
if (mapped) return mapped;
|
|
31
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(ref.field)) return ref.field;
|
|
32
|
+
return toEnvName(`${ref.item} ${ref.field}`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1Password strategy — reads/writes item fields with the `op` CLI.
|
|
3
|
+
* Selected with `secrets.provider: 1password`.
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from "child_process";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
import type { PreflightResult, SecretRef, SecretsProvider } from "./types.js";
|
|
8
|
+
|
|
9
|
+
import { product } from "../product.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
export interface OnePasswordOptions {
|
|
13
|
+
account?: string;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class OnePasswordProvider implements SecretsProvider {
|
|
18
|
+
readonly name = "1password" as const;
|
|
19
|
+
private readonly timeout: number;
|
|
20
|
+
|
|
21
|
+
constructor(opts: OnePasswordOptions = {}) {
|
|
22
|
+
this.timeout = opts.timeoutMs ?? 60_000;
|
|
23
|
+
if (opts.account) process.env.OP_ACCOUNT ||= opts.account;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async isReady(): Promise<boolean> {
|
|
27
|
+
try {
|
|
28
|
+
await execFileAsync("op", ["whoami"], { timeout: this.timeout });
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async get(ref: SecretRef): Promise<string> {
|
|
36
|
+
// Direct CLI args rather than an op:// URI — URIs break on "(" "," etc.
|
|
37
|
+
const args = ["item", "get", ref.item];
|
|
38
|
+
if (ref.vault) args.push("--vault", ref.vault);
|
|
39
|
+
args.push("--fields", `label=${ref.field}`, "--reveal");
|
|
40
|
+
const { stdout } = await execFileAsync("op", args, {
|
|
41
|
+
timeout: this.timeout,
|
|
42
|
+
});
|
|
43
|
+
return String(stdout).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async tryGet(ref: SecretRef): Promise<string | null> {
|
|
47
|
+
try {
|
|
48
|
+
const v = await this.get(ref);
|
|
49
|
+
return v.length > 0 ? v : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async set(ref: SecretRef, value: string): Promise<void> {
|
|
56
|
+
const args = ["item", "edit", ref.item];
|
|
57
|
+
if (ref.vault) args.push("--vault", ref.vault);
|
|
58
|
+
args.push(`${ref.field}[password]=${value}`);
|
|
59
|
+
await execFileAsync("op", args, { timeout: this.timeout });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async preflight(): Promise<PreflightResult> {
|
|
63
|
+
if (await this.isReady()) return { ok: true };
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
message:
|
|
67
|
+
"1Password CLI is not signed in. Run: eval $(op signin)\n" +
|
|
68
|
+
`Or switch backends with secrets.provider in ${product().configFile}.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
runPrefix(): string[] {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets strategy — the contract every secrets backend implements.
|
|
3
|
+
*
|
|
4
|
+
* Den never talks to a vault directly. Callers describe WHAT they need as
|
|
5
|
+
* a {@link SecretRef} (a logical vault / item / field triple) and the active
|
|
6
|
+
* {@link SecretsProvider} decides HOW to fetch it: 1Password item fields,
|
|
7
|
+
* Doppler flat keys, or plain environment variables. The backend is selected
|
|
8
|
+
* by configuration (see `resolveSecretsProvider`), never hard-wired.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A logical pointer to one secret value. */
|
|
12
|
+
export interface SecretRef {
|
|
13
|
+
/** Logical vault/namespace (1Password vault; ignored by flat-key backends). */
|
|
14
|
+
vault: string;
|
|
15
|
+
/** Logical item within the vault (1Password item name or ID). */
|
|
16
|
+
item: string;
|
|
17
|
+
/** Field within the item (e.g. "password", "username", "OPENAI_API_KEY"). */
|
|
18
|
+
field: string;
|
|
19
|
+
/**
|
|
20
|
+
* Flat key for key/value backends (Doppler, env). When omitted it is taken
|
|
21
|
+
* from the `secrets.keys` map in config/den.yml, else derived — see
|
|
22
|
+
* `flatKeyFor`.
|
|
23
|
+
*/
|
|
24
|
+
key?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SecretsProviderName = "doppler" | "1password" | "env";
|
|
28
|
+
|
|
29
|
+
export interface PreflightResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
/** Human-readable remediation when !ok. */
|
|
32
|
+
message?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SecretsProvider {
|
|
36
|
+
readonly name: SecretsProviderName;
|
|
37
|
+
/** True when the backend CLI is installed and authenticated. Never throws. */
|
|
38
|
+
isReady(): Promise<boolean>;
|
|
39
|
+
/** Read a secret. Throws when it cannot be resolved. */
|
|
40
|
+
get(ref: SecretRef): Promise<string>;
|
|
41
|
+
/** Read a secret, returning null instead of throwing. */
|
|
42
|
+
tryGet(ref: SecretRef): Promise<string | null>;
|
|
43
|
+
/** Write (create or overwrite) a secret. Throws on failure. */
|
|
44
|
+
set(ref: SecretRef, value: string): Promise<void>;
|
|
45
|
+
/** Check the backend is usable before `den up`/`restart`. */
|
|
46
|
+
preflight(): Promise<PreflightResult>;
|
|
47
|
+
/**
|
|
48
|
+
* argv prefix that runs a child command with secrets injected as env vars,
|
|
49
|
+
* e.g. `["doppler","run","--"]`. Empty when the backend injects nothing.
|
|
50
|
+
*/
|
|
51
|
+
runPrefix(): string[];
|
|
52
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Environment,
|
|
3
|
+
RawManifest,
|
|
4
|
+
ServiceHealthConfig,
|
|
5
|
+
} from "./types/index.js";
|
|
6
|
+
|
|
7
|
+
export interface ResolveManifestBaseUrlOptions {
|
|
8
|
+
manifest: RawManifest;
|
|
9
|
+
repoName: string;
|
|
10
|
+
serviceName: string;
|
|
11
|
+
environment: Environment;
|
|
12
|
+
baseUrlKey: "api" | "web";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResolveServiceBaseUrlOptions {
|
|
16
|
+
baseUrlKey: "api" | "web";
|
|
17
|
+
configuredUrl: string;
|
|
18
|
+
manifestUrl?: string | null;
|
|
19
|
+
overrideUrl?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function healthUrlForConfig(healthConfig?: ServiceHealthConfig): string | null {
|
|
23
|
+
if (!healthConfig) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if ("type" in healthConfig && healthConfig.type === "shell") {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return healthConfig.url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeServiceBaseUrl(
|
|
35
|
+
healthUrl: string,
|
|
36
|
+
baseUrlKey: "api" | "web",
|
|
37
|
+
): string {
|
|
38
|
+
if (baseUrlKey !== "api") {
|
|
39
|
+
return healthUrl;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const url = new URL(healthUrl);
|
|
43
|
+
url.pathname =
|
|
44
|
+
url.pathname.replace(/\/(healthz|readyz|version)\/?$/, "") || "/";
|
|
45
|
+
|
|
46
|
+
const normalized = url.toString();
|
|
47
|
+
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive a base URL from the manifest's `services.*.health.<env>` entry.
|
|
52
|
+
*
|
|
53
|
+
* Lookup order: `services[serviceName]`, then `services[repoName]`, then the
|
|
54
|
+
* manifest's declared `entrypoint`.
|
|
55
|
+
*
|
|
56
|
+
* The entrypoint step matters because `serviceName` here is the *pillar module*
|
|
57
|
+
* name (smoke passes `svcName` from `smoke.<name>`, perf passes
|
|
58
|
+
* `moduleConfig.service`), which need not match any `services:` key. A repo
|
|
59
|
+
* whose modules are named for what they test rather than for the service —
|
|
60
|
+
* `tier1`, `search`, `ingest` — matched neither of the first two lookups, so
|
|
61
|
+
* this returned null and callers silently fell back to
|
|
62
|
+
* `config/environments/<env>.json -> baseUrls.api`, i.e. shop's API. Repos
|
|
63
|
+
* appeared to work only when a module happened to share the service's name.
|
|
64
|
+
*
|
|
65
|
+
* `entrypoint` is precisely the manifest's statement of which service is the
|
|
66
|
+
* front door, and `service-runner.ts` already resolves an ambiguous subsystem
|
|
67
|
+
* the same way.
|
|
68
|
+
*
|
|
69
|
+
* For `baseUrlKey === "api"`, strips trailing `/healthz`, `/readyz`, or
|
|
70
|
+
* `/version` so the health endpoint becomes a usable API root.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveManifestBaseUrl({
|
|
73
|
+
manifest,
|
|
74
|
+
repoName,
|
|
75
|
+
serviceName,
|
|
76
|
+
environment,
|
|
77
|
+
baseUrlKey,
|
|
78
|
+
}: ResolveManifestBaseUrlOptions): string | null {
|
|
79
|
+
const entrypointConfig = manifest.entrypoint
|
|
80
|
+
? manifest.services?.[manifest.entrypoint]
|
|
81
|
+
: undefined;
|
|
82
|
+
const serviceConfig =
|
|
83
|
+
manifest.services?.[serviceName] ??
|
|
84
|
+
manifest.services?.[repoName] ??
|
|
85
|
+
entrypointConfig;
|
|
86
|
+
const healthConfig = serviceConfig?.health?.[environment];
|
|
87
|
+
const healthUrl = healthUrlForConfig(healthConfig);
|
|
88
|
+
if (!healthUrl) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return normalizeServiceBaseUrl(healthUrl, baseUrlKey);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Precedence-based URL resolver shared across pillars.
|
|
97
|
+
*
|
|
98
|
+
* 1. Explicit override (`BASE_API_URL` env, only for `baseUrlKey === "api"`)
|
|
99
|
+
* 2. Manifest-derived URL from `services.*.health.<env>`
|
|
100
|
+
* 3. Configured URL from `config/environments/<env>.json`
|
|
101
|
+
*/
|
|
102
|
+
export function resolveServiceBaseUrl({
|
|
103
|
+
baseUrlKey,
|
|
104
|
+
configuredUrl,
|
|
105
|
+
manifestUrl,
|
|
106
|
+
overrideUrl,
|
|
107
|
+
}: ResolveServiceBaseUrlOptions): string {
|
|
108
|
+
const trimmedOverride = baseUrlKey === "api" ? overrideUrl?.trim() : "";
|
|
109
|
+
if (trimmedOverride) {
|
|
110
|
+
return trimmedOverride;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const trimmedManifestUrl = manifestUrl?.trim();
|
|
114
|
+
if (trimmedManifestUrl) {
|
|
115
|
+
return trimmedManifestUrl;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return configuredUrl;
|
|
119
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployed branch/SHA resolver
|
|
3
|
+
*
|
|
4
|
+
* Uses the GitHub Deployments API (via `gh`) to find the exact commit SHA
|
|
5
|
+
* that is currently deployed to a given environment. This is the authoritative
|
|
6
|
+
* source of truth — more precise than a branch name, and independent of what
|
|
7
|
+
* happens to be on the local machine.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. GET /repos/{owner}/{repo}/deployments?environment={env}&per_page=20
|
|
11
|
+
* 2. Walk deployments newest-first, fetch each one's statuses
|
|
12
|
+
* 3. Return the first deployment whose latest status is "success"
|
|
13
|
+
*
|
|
14
|
+
* Why not just take deployment[0]? A deployment record is created when a
|
|
15
|
+
* deploy *starts*, not when it *succeeds*. If the most recent deploy is still
|
|
16
|
+
* in progress or failed, we want the last known-good one.
|
|
17
|
+
*
|
|
18
|
+
* Requires: `gh` CLI authenticated (`gh auth login`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execSync } from "child_process";
|
|
22
|
+
|
|
23
|
+
export interface DeployedRef {
|
|
24
|
+
/** Exact commit SHA deployed to this environment. */
|
|
25
|
+
sha: string;
|
|
26
|
+
/** Branch the commit was deployed from (informational). */
|
|
27
|
+
ref: string;
|
|
28
|
+
/** GitHub environment name. */
|
|
29
|
+
environment: string;
|
|
30
|
+
/** ISO timestamp of the deployment. */
|
|
31
|
+
deployedAt: string;
|
|
32
|
+
/** GitHub deployment ID. */
|
|
33
|
+
deploymentId: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface GhDeployment {
|
|
37
|
+
id: number;
|
|
38
|
+
sha: string;
|
|
39
|
+
ref: string;
|
|
40
|
+
environment: string;
|
|
41
|
+
created_at: string;
|
|
42
|
+
statuses_url: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface GhDeploymentStatus {
|
|
46
|
+
state: string; // "success" | "failure" | "error" | "pending" | "in_progress" | "queued" | "inactive"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function gh(args: string): string {
|
|
50
|
+
try {
|
|
51
|
+
return execSync(`gh ${args}`, {
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
}).trim();
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const msg = (err as { stderr?: string }).stderr?.trim() ?? String(err);
|
|
57
|
+
throw new Error(`gh command failed: ${msg}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetch the latest deployment status state for a deployment.
|
|
63
|
+
* Returns null if no statuses exist yet (deployment just created).
|
|
64
|
+
*/
|
|
65
|
+
function getLatestDeploymentState(
|
|
66
|
+
owner: string,
|
|
67
|
+
repo: string,
|
|
68
|
+
deploymentId: number,
|
|
69
|
+
): string | null {
|
|
70
|
+
try {
|
|
71
|
+
const raw = gh(
|
|
72
|
+
`api "/repos/${owner}/${repo}/deployments/${deploymentId}/statuses?per_page=1"`,
|
|
73
|
+
);
|
|
74
|
+
const statuses: GhDeploymentStatus[] = JSON.parse(raw);
|
|
75
|
+
return statuses[0]?.state ?? null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the latest *successfully* deployed ref for a repo+environment.
|
|
83
|
+
*
|
|
84
|
+
* @param owner GitHub org/user (e.g. "acme")
|
|
85
|
+
* @param repo GitHub repo name (e.g. "api")
|
|
86
|
+
* @param environment GitHub environment name (e.g. "staging")
|
|
87
|
+
* @param limit Max deployments to inspect before giving up (default 20)
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveDeployedRef(
|
|
90
|
+
owner: string,
|
|
91
|
+
repo: string,
|
|
92
|
+
environment: string,
|
|
93
|
+
limit = 20,
|
|
94
|
+
): Promise<DeployedRef> {
|
|
95
|
+
// Check gh is available and authenticated
|
|
96
|
+
try {
|
|
97
|
+
gh("auth status --active");
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const raw = gh(
|
|
103
|
+
`api "/repos/${owner}/${repo}/deployments?environment=${environment}&per_page=${limit}"`,
|
|
104
|
+
);
|
|
105
|
+
const deployments: GhDeployment[] = JSON.parse(raw);
|
|
106
|
+
|
|
107
|
+
if (!deployments.length) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`No deployments found for ${owner}/${repo} on environment "${environment}". ` +
|
|
110
|
+
`Has it been deployed via GitHub Actions?`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Walk newest-first, find the last successful one
|
|
115
|
+
for (const d of deployments) {
|
|
116
|
+
const state = getLatestDeploymentState(owner, repo, d.id);
|
|
117
|
+
if (state === "success") {
|
|
118
|
+
return {
|
|
119
|
+
sha: d.sha,
|
|
120
|
+
ref: d.ref,
|
|
121
|
+
environment: d.environment,
|
|
122
|
+
deployedAt: d.created_at,
|
|
123
|
+
deploymentId: d.id,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// No successful deployment found in the window — warn and use most recent
|
|
129
|
+
const latest = deployments[0];
|
|
130
|
+
console.warn(
|
|
131
|
+
` ⚠ No successful deployment found for ${owner}/${repo}@${environment} ` +
|
|
132
|
+
`in the last ${limit} deploys. Using most recent (${latest.sha.slice(0, 8)}).`,
|
|
133
|
+
);
|
|
134
|
+
return {
|
|
135
|
+
sha: latest.sha,
|
|
136
|
+
ref: latest.ref,
|
|
137
|
+
environment: latest.environment,
|
|
138
|
+
deployedAt: latest.created_at,
|
|
139
|
+
deploymentId: latest.id,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git remote URL utilities
|
|
3
|
+
*
|
|
4
|
+
* Extracts the GitHub owner/repo from a git remote URL so the rest of the
|
|
5
|
+
* smoke pipeline can call the GitHub API without requiring this info to be
|
|
6
|
+
* hardcoded in .den.yml.
|
|
7
|
+
*
|
|
8
|
+
* Supported URL formats:
|
|
9
|
+
* https://github.com/acme/api.git
|
|
10
|
+
* https://github.com/acme/api
|
|
11
|
+
* git@github.com:acme/api.git
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from "child_process";
|
|
15
|
+
|
|
16
|
+
export interface GitHubCoords {
|
|
17
|
+
owner: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run `git remote get-url origin` inside the given directory.
|
|
23
|
+
* Returns the raw remote URL string, or null if the directory is not a git
|
|
24
|
+
* repo or has no origin remote.
|
|
25
|
+
*/
|
|
26
|
+
export function getGitRemoteOrigin(repoPath: string): string | null {
|
|
27
|
+
try {
|
|
28
|
+
return execSync("git remote get-url origin", {
|
|
29
|
+
cwd: repoPath,
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
}).trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse GitHub owner and repo from a remote URL.
|
|
40
|
+
* Returns null if the URL is not a recognisable GitHub URL.
|
|
41
|
+
*/
|
|
42
|
+
export function parseGitHubOwnerRepo(remoteUrl: string): GitHubCoords | null {
|
|
43
|
+
// HTTPS: https://github.com/owner/repo[.git]
|
|
44
|
+
const httpsMatch = remoteUrl.match(
|
|
45
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
|
|
46
|
+
);
|
|
47
|
+
if (httpsMatch) {
|
|
48
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// SSH: git@github.com:owner/repo[.git]
|
|
52
|
+
const sshMatch = remoteUrl.match(
|
|
53
|
+
/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/,
|
|
54
|
+
);
|
|
55
|
+
if (sshMatch) {
|
|
56
|
+
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convenience: get GitHub coords for a local repo path.
|
|
64
|
+
* Returns null if the remote is absent or not a GitHub remote.
|
|
65
|
+
*/
|
|
66
|
+
export function getGitHubCoords(repoPath: string): GitHubCoords | null {
|
|
67
|
+
const origin = getGitRemoteOrigin(repoPath);
|
|
68
|
+
if (!origin) return null;
|
|
69
|
+
return parseGitHubOwnerRepo(origin);
|
|
70
|
+
}
|