@wtfalch/foundry 0.60.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +5 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/org.d.ts +163 -22
- package/dist/org.js +419 -47
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +41 -1
- package/dist/plan/blue-green.js +80 -2
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/desktop-callback.js +12 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +373 -42
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +191 -1
- package/dist/plan/executor.js +1505 -90
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +2 -2
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.js +12 -1
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +7 -2
- package/dist/plan/post-logout-uris.js +7 -1
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +11 -0
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +10 -6
- package/dist/store/postgres.js +12 -2
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +4 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +18 -1
package/dist/plan/gates.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export declare function isBehindGate(org: DeclaredOrg, hostname: string): boolea
|
|
|
36
36
|
* owner with an email: when one is missing no create is planned and
|
|
37
37
|
* `blocked` says why (CLAUDE.md rule 2).
|
|
38
38
|
*/
|
|
39
|
-
export declare function planGates(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, providerId: string | null): {
|
|
39
|
+
export declare function planGates(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, providerId: string | null, declaredOrg?: DeclaredOrg): {
|
|
40
40
|
steps: DraftStep[];
|
|
41
41
|
blocked: string | null;
|
|
42
42
|
};
|
package/dist/plan/gates.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
2
|
+
import { workspaceHost, workspaceInboxHost } from './hostnames.js';
|
|
2
3
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
3
4
|
import { appStepKey } from './platform-diff.js';
|
|
5
|
+
import { privatePageGates } from './private-pages.js';
|
|
6
|
+
import { INBOX_GATE_KEY, companyPeople, gatePeopleScopeKey, hasInbox, isServerWorkspace, peopleHash, workspaceEntries, workspaceGateKey, workspacePeople, } from './workspace.js';
|
|
4
7
|
/**
|
|
5
8
|
* A company's declared gates (`HostingEntry.gates`) as Cloudflare Access gates
|
|
6
9
|
* made through edge. Foundry sets who passes once, on create, from the
|
|
@@ -25,8 +28,8 @@ export function resolveGateHostnames(domain, names) {
|
|
|
25
28
|
.map((name) => (name === '@' ? domain : name === '*' ? `*.${domain}` : `${name}.${domain}`))
|
|
26
29
|
.sort();
|
|
27
30
|
}
|
|
28
|
-
/** Every
|
|
29
|
-
function
|
|
31
|
+
/** Every gate an entry declares on its own hostnames, by key; none without hosting or a domain. */
|
|
32
|
+
function entryGates(org) {
|
|
30
33
|
const gates = new Map();
|
|
31
34
|
if (!org.hosting.enabled || !org.domain)
|
|
32
35
|
return gates;
|
|
@@ -40,9 +43,34 @@ function declaredGates(org) {
|
|
|
40
43
|
}
|
|
41
44
|
return gates;
|
|
42
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The entry gates, the private page gates (`private-pages.ts`) and one gate per workspace (`ws-<entry key>`, over the workspace host, whose people are the
|
|
48
|
+
* company's and the entry's testers). Workspaces are read from `declared`, the org with its dark entries.
|
|
49
|
+
*/
|
|
50
|
+
function declaredGates(org, declared) {
|
|
51
|
+
const gates = entryGates(org);
|
|
52
|
+
for (const entry of workspaceEntries(declared)) {
|
|
53
|
+
gates.set(workspaceGateKey(entry.key), {
|
|
54
|
+
entry: entry.key,
|
|
55
|
+
hostnames: [workspaceHost(declared.slug, entry.key)],
|
|
56
|
+
people: workspacePeople(declared, entry),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const first = workspaceEntries(declared).find(isServerWorkspace);
|
|
60
|
+
if (first && hasInbox(declared)) {
|
|
61
|
+
gates.set(INBOX_GATE_KEY, {
|
|
62
|
+
entry: first.key,
|
|
63
|
+
hostnames: [workspaceInboxHost(declared.slug)],
|
|
64
|
+
people: companyPeople(declared),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
for (const [key, gate] of privatePageGates(org, declared))
|
|
68
|
+
gates.set(key, gate);
|
|
69
|
+
return gates;
|
|
70
|
+
}
|
|
43
71
|
/** True when a declared gate resolves to `*.<domain>`. */
|
|
44
72
|
export function hasWildcardGate(org) {
|
|
45
|
-
return [...
|
|
73
|
+
return [...entryGates(org).values()].some(({ hostnames }) => hostnames.some((name) => name.startsWith('*.')));
|
|
46
74
|
}
|
|
47
75
|
/**
|
|
48
76
|
* True when a declared gate covers `hostname`, exactly or through a gated
|
|
@@ -55,7 +83,7 @@ export function isBehindGate(org, hostname) {
|
|
|
55
83
|
if ((org.hosting.access ?? []).some((r) => r.kind === 'bypass' && r.hostname === hostname)) {
|
|
56
84
|
return false;
|
|
57
85
|
}
|
|
58
|
-
return [...
|
|
86
|
+
return [...entryGates(org).values()].some(({ hostnames }) => hostnames.some((name) => name.startsWith('*.') ? hostname.endsWith(name.slice(1)) : name === hostname));
|
|
59
87
|
}
|
|
60
88
|
/**
|
|
61
89
|
* One step per declared gate whose recorded id or hostnames differ from what
|
|
@@ -65,7 +93,7 @@ export function isBehindGate(org, hostname) {
|
|
|
65
93
|
* owner with an email: when one is missing no create is planned and
|
|
66
94
|
* `blocked` says why (CLAUDE.md rule 2).
|
|
67
95
|
*/
|
|
68
|
-
export function planGates(org, recordedIds, providerId) {
|
|
96
|
+
export function planGates(org, recordedIds, providerId, declaredOrg = org) {
|
|
69
97
|
const steps = [];
|
|
70
98
|
let blocked = null;
|
|
71
99
|
const missing = !providerId
|
|
@@ -75,7 +103,7 @@ export function planGates(org, recordedIds, providerId) {
|
|
|
75
103
|
: org.owners.length === 0
|
|
76
104
|
? `${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`
|
|
77
105
|
: null;
|
|
78
|
-
const declared = declaredGates(org);
|
|
106
|
+
const declared = declaredGates(org, declaredOrg);
|
|
79
107
|
const prefix = gateIdRecordKey(org.id, '');
|
|
80
108
|
for (const [name, id] of Object.entries(recordedIds)) {
|
|
81
109
|
if (!id || !name.startsWith(prefix))
|
|
@@ -97,11 +125,30 @@ export function planGates(org, recordedIds, providerId) {
|
|
|
97
125
|
},
|
|
98
126
|
});
|
|
99
127
|
}
|
|
100
|
-
for (const [gateKey, { entry, hostnames }] of declared) {
|
|
128
|
+
for (const [gateKey, { entry, hostnames, people }] of declared) {
|
|
101
129
|
const recorded = recordedIds[gateIdRecordKey(org.id, gateKey)];
|
|
102
|
-
|
|
130
|
+
const hostnamesSame = Boolean(recorded) &&
|
|
131
|
+
recordedIds[gateHostnamesScopeKey(gateKey)] === JSON.stringify(hostnames);
|
|
132
|
+
if (hostnamesSame && people) {
|
|
133
|
+
if (recordedIds[gatePeopleScopeKey(gateKey)] !== peopleHash(people)) {
|
|
134
|
+
steps.push({
|
|
135
|
+
action: {
|
|
136
|
+
system: 'cloudflare',
|
|
137
|
+
kind: 'gate',
|
|
138
|
+
key: appStepKey(org, gateKey),
|
|
139
|
+
desired: { mode: 'people', entry, emails: people },
|
|
140
|
+
},
|
|
141
|
+
step: {
|
|
142
|
+
description: `change who may open ${org.slug}'s '${gateKey}' Cloudflare Access gate to ${people.length} people`,
|
|
143
|
+
destroys: false,
|
|
144
|
+
migrates: false,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
103
148
|
continue;
|
|
104
149
|
}
|
|
150
|
+
if (hostnamesSame)
|
|
151
|
+
continue;
|
|
105
152
|
if (!recorded && missing) {
|
|
106
153
|
blocked = missing;
|
|
107
154
|
continue;
|
|
@@ -111,7 +158,13 @@ export function planGates(org, recordedIds, providerId) {
|
|
|
111
158
|
system: 'cloudflare',
|
|
112
159
|
kind: 'gate',
|
|
113
160
|
key: appStepKey(org, gateKey),
|
|
114
|
-
desired: {
|
|
161
|
+
desired: {
|
|
162
|
+
mode: 'apply',
|
|
163
|
+
entry,
|
|
164
|
+
hostnames,
|
|
165
|
+
providerId,
|
|
166
|
+
...(people ? { emails: people } : {}),
|
|
167
|
+
},
|
|
115
168
|
},
|
|
116
169
|
step: {
|
|
117
170
|
description: recorded
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F12: GitHub deploy keys for an entry's own repo, and the ed25519 keypair behind one.
|
|
3
|
+
*
|
|
4
|
+
* Which GitHub credential makes the key:
|
|
5
|
+
* - a repo under the company's installation (`hosting.github`): an installation token of the platform GitHub App
|
|
6
|
+
* (`PLATFORM_GITHUB_APP_REFS`), asked for `administration: write` on that one repo;
|
|
7
|
+
* - a repo under `wtfalch`: the same App's installation on that account, found with `GET /orgs/<owner>/installation`
|
|
8
|
+
* (cached for the life of the client) and asked for the same token.
|
|
9
|
+
* Both need the repository permission "Administration: Read and write"; creating a deploy key, even a read-only
|
|
10
|
+
* one, is refused without it. Neither is documented to hold it today, so a refusal is a plain blocked line, not a
|
|
11
|
+
* failure.
|
|
12
|
+
*/
|
|
13
|
+
/** Every GitHub call is cut off after this long. */
|
|
14
|
+
export declare const GITHUB_DEPLOY_KEYS_TIMEOUT_MS = 20000;
|
|
15
|
+
/** A repo and, for one under the company's installation, that installation's id. */
|
|
16
|
+
export interface DeployKeyTarget {
|
|
17
|
+
/** `<owner>/<name>`. */
|
|
18
|
+
repo: string;
|
|
19
|
+
installationId?: number;
|
|
20
|
+
}
|
|
21
|
+
/** What GitHub says about a deploy key. */
|
|
22
|
+
export interface GithubDeployKey {
|
|
23
|
+
id: number;
|
|
24
|
+
title: string;
|
|
25
|
+
/** `ssh-ed25519 <base64>` (GitHub drops any comment). */
|
|
26
|
+
key: string;
|
|
27
|
+
readOnly: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface GithubDeployKeysPort {
|
|
30
|
+
list(target: DeployKeyTarget): Promise<GithubDeployKey[]>;
|
|
31
|
+
create(target: DeployKeyTarget, key: {
|
|
32
|
+
title: string;
|
|
33
|
+
key: string;
|
|
34
|
+
readOnly: boolean;
|
|
35
|
+
}): Promise<GithubDeployKey>;
|
|
36
|
+
/** A key that is already gone counts as deleted. */
|
|
37
|
+
remove(target: DeployKeyTarget, id: number): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/** Why foundry cannot make deploy keys for a repo. The text is plain and fixed; it holds no credential. */
|
|
40
|
+
export declare class DeployKeysBlocked extends Error {
|
|
41
|
+
readonly reason: 'no-credential' | 'permission' | 'unreachable';
|
|
42
|
+
constructor(reason: 'no-credential' | 'permission' | 'unreachable', message: string);
|
|
43
|
+
}
|
|
44
|
+
/** The line for a blocked repo: what is missing, and whose step it is. */
|
|
45
|
+
export declare function deployKeysBlockedLine(target: DeployKeyTarget, reason: DeployKeysBlocked['reason']): string;
|
|
46
|
+
/** A 9-minute RS256 JWT for the App, as GitHub asks for before an installation token. */
|
|
47
|
+
export declare function githubAppJwt(appId: string, privateKeyPem: string, nowMs: number): string;
|
|
48
|
+
export interface GithubDeployKeysOptions {
|
|
49
|
+
heldKeys: {
|
|
50
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
51
|
+
};
|
|
52
|
+
/** Global fetch by default; tests give their own. */
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
now?: () => number;
|
|
56
|
+
/** Told of every token before it is used, so the host can scrub it from logs and audit rows. */
|
|
57
|
+
onSecret?: (value: string) => void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The GitHub REST deploy-key calls over `fetchImpl`, a timeout on each. Errors are fixed text: never a token, a key
|
|
61
|
+
* or a response body. A 401, 403, 404 or 422 from the credential or the key call is a {@link DeployKeysBlocked}
|
|
62
|
+
* `permission`; no answer is `unreachable`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createGithubDeployKeys(options: GithubDeployKeysOptions): GithubDeployKeysPort;
|
|
65
|
+
/** The OpenSSH public key line (`ssh-ed25519 <base64>`) and private key file text of a fresh ed25519 pair. */
|
|
66
|
+
export interface RepoKeyPair {
|
|
67
|
+
publicKey: string;
|
|
68
|
+
privateKey: string;
|
|
69
|
+
}
|
|
70
|
+
/** A new ed25519 keypair; the private key is an unencrypted `-----BEGIN OPENSSH PRIVATE KEY-----` file, as ssh and git read it. */
|
|
71
|
+
export declare function generateRepoKeyPair(): RepoKeyPair;
|
|
72
|
+
/**
|
|
73
|
+
* The public key line (`ssh-ed25519 <base64>`) inside an unencrypted OpenSSH private key file, or `null` when the
|
|
74
|
+
* text is not one (so a held value that is not a usable key is replaced, not trusted).
|
|
75
|
+
*/
|
|
76
|
+
export declare function publicKeyOfPrivate(privateKey: string): string | null;
|
|
77
|
+
/** True when two `type base64` lines name the same key (a trailing comment is ignored). */
|
|
78
|
+
export declare const sameSshKey: (a: string, b: string) => boolean;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { createSign, generateKeyPairSync } from 'node:crypto';
|
|
2
|
+
import { PLATFORM_GITHUB_APP_REFS } from './held-key-refs.js';
|
|
3
|
+
/**
|
|
4
|
+
* F12: GitHub deploy keys for an entry's own repo, and the ed25519 keypair behind one.
|
|
5
|
+
*
|
|
6
|
+
* Which GitHub credential makes the key:
|
|
7
|
+
* - a repo under the company's installation (`hosting.github`): an installation token of the platform GitHub App
|
|
8
|
+
* (`PLATFORM_GITHUB_APP_REFS`), asked for `administration: write` on that one repo;
|
|
9
|
+
* - a repo under `wtfalch`: the same App's installation on that account, found with `GET /orgs/<owner>/installation`
|
|
10
|
+
* (cached for the life of the client) and asked for the same token.
|
|
11
|
+
* Both need the repository permission "Administration: Read and write"; creating a deploy key, even a read-only
|
|
12
|
+
* one, is refused without it. Neither is documented to hold it today, so a refusal is a plain blocked line, not a
|
|
13
|
+
* failure.
|
|
14
|
+
*/
|
|
15
|
+
/** Every GitHub call is cut off after this long. */
|
|
16
|
+
export const GITHUB_DEPLOY_KEYS_TIMEOUT_MS = 20_000;
|
|
17
|
+
const GITHUB_API = 'https://api.github.com';
|
|
18
|
+
const MAX_PAGES = 10;
|
|
19
|
+
const PAGE_SIZE = 100;
|
|
20
|
+
/** Why foundry cannot make deploy keys for a repo. The text is plain and fixed; it holds no credential. */
|
|
21
|
+
export class DeployKeysBlocked extends Error {
|
|
22
|
+
reason;
|
|
23
|
+
constructor(reason, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
this.name = 'DeployKeysBlocked';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** The line for a blocked repo: what is missing, and whose step it is. */
|
|
30
|
+
export function deployKeysBlockedLine(target, reason) {
|
|
31
|
+
if (reason === 'unreachable') {
|
|
32
|
+
return `GitHub did not answer for ${target.repo}, so its deploy key could not be checked; the next plan tries again`;
|
|
33
|
+
}
|
|
34
|
+
if (reason === 'no-credential') {
|
|
35
|
+
return `the platform GitHub App keys (${Object.values(PLATFORM_GITHUB_APP_REFS).join(', ')}) are not all held, so ${target.repo} cannot get a deploy key; William's step: hold them in Archon, Shared, Keys`;
|
|
36
|
+
}
|
|
37
|
+
if (target.installationId === undefined) {
|
|
38
|
+
return `the platform GitHub App needs Administration: Read and write and an installation on ${target.repo.split('/')[0]} (William's step, once for the platform)`;
|
|
39
|
+
}
|
|
40
|
+
return `GitHub refused deploy keys on ${target.repo}: the platform GitHub App needs the repository permission "Administration: Read and write"; William's step: grant it in the App's settings, then the owner of the installation accepts the new permission on GitHub`;
|
|
41
|
+
}
|
|
42
|
+
const b64url = (value) => Buffer.from(value).toString('base64url');
|
|
43
|
+
/** A 9-minute RS256 JWT for the App, as GitHub asks for before an installation token. */
|
|
44
|
+
export function githubAppJwt(appId, privateKeyPem, nowMs) {
|
|
45
|
+
const iat = Math.floor(nowMs / 1000) - 60;
|
|
46
|
+
const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
47
|
+
const body = b64url(JSON.stringify({ iat, exp: iat + 600, iss: appId }));
|
|
48
|
+
const signature = createSign('RSA-SHA256').update(`${head}.${body}`).sign(privateKeyPem);
|
|
49
|
+
return `${head}.${body}.${b64url(signature)}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The GitHub REST deploy-key calls over `fetchImpl`, a timeout on each. Errors are fixed text: never a token, a key
|
|
53
|
+
* or a response body. A 401, 403, 404 or 422 from the credential or the key call is a {@link DeployKeysBlocked}
|
|
54
|
+
* `permission`; no answer is `unreachable`.
|
|
55
|
+
*/
|
|
56
|
+
export function createGithubDeployKeys(options) {
|
|
57
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
58
|
+
const timeoutMs = options.timeoutMs ?? GITHUB_DEPLOY_KEYS_TIMEOUT_MS;
|
|
59
|
+
const now = options.now ?? Date.now;
|
|
60
|
+
const secret = (value) => options.onSecret?.(value);
|
|
61
|
+
const send = async (token, method, path, body) => {
|
|
62
|
+
let response;
|
|
63
|
+
try {
|
|
64
|
+
response = await doFetch(`${GITHUB_API}${path}`, {
|
|
65
|
+
method,
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${token}`,
|
|
68
|
+
Accept: 'application/vnd.github+json',
|
|
69
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
70
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
71
|
+
},
|
|
72
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
73
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new DeployKeysBlocked('unreachable', 'GitHub did not answer');
|
|
78
|
+
}
|
|
79
|
+
let json = null;
|
|
80
|
+
try {
|
|
81
|
+
json = await response.json();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Empty body (a delete) or not JSON: the status decides.
|
|
85
|
+
}
|
|
86
|
+
return { status: response.status, json };
|
|
87
|
+
};
|
|
88
|
+
const installations = new Map();
|
|
89
|
+
const credential = async (target) => {
|
|
90
|
+
const appId = await options.heldKeys.resolve(PLATFORM_GITHUB_APP_REFS.appId);
|
|
91
|
+
const pem = await options.heldKeys.resolve(PLATFORM_GITHUB_APP_REFS.privateKey);
|
|
92
|
+
if (!appId || !pem)
|
|
93
|
+
throw new DeployKeysBlocked('no-credential', 'App keys not held');
|
|
94
|
+
secret(pem);
|
|
95
|
+
let jwt;
|
|
96
|
+
try {
|
|
97
|
+
jwt = githubAppJwt(appId, pem, now());
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new DeployKeysBlocked('no-credential', 'App key unusable');
|
|
101
|
+
}
|
|
102
|
+
secret(jwt);
|
|
103
|
+
const owner = target.repo.slice(0, target.repo.indexOf('/'));
|
|
104
|
+
const name = target.repo.slice(target.repo.indexOf('/') + 1);
|
|
105
|
+
let installationId = target.installationId ?? installations.get(owner);
|
|
106
|
+
if (installationId === undefined) {
|
|
107
|
+
const found = await send(jwt, 'GET', `/orgs/${owner}/installation`);
|
|
108
|
+
const id = found.json?.id;
|
|
109
|
+
if (found.status === 200 && typeof id === 'number') {
|
|
110
|
+
installationId = id;
|
|
111
|
+
installations.set(owner, id);
|
|
112
|
+
}
|
|
113
|
+
else if (refused(found.status)) {
|
|
114
|
+
throw new DeployKeysBlocked('permission', 'no installation');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
throw new DeployKeysBlocked('unreachable', 'installation not found');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const { status, json } = await send(jwt, 'POST', `/app/installations/${installationId}/access_tokens`, { repositories: [name], permissions: { administration: 'write' } });
|
|
121
|
+
const token = json?.token;
|
|
122
|
+
if (status >= 200 && status < 300 && typeof token === 'string' && token) {
|
|
123
|
+
secret(token);
|
|
124
|
+
return token;
|
|
125
|
+
}
|
|
126
|
+
if (status === 401 || status === 403 || status === 404 || status === 422) {
|
|
127
|
+
throw new DeployKeysBlocked('permission', 'installation token refused');
|
|
128
|
+
}
|
|
129
|
+
throw new DeployKeysBlocked('unreachable', 'installation token not given');
|
|
130
|
+
};
|
|
131
|
+
const refused = (status) => status === 401 || status === 403 || status === 404 || status === 422;
|
|
132
|
+
const asKey = (value) => {
|
|
133
|
+
const v = value;
|
|
134
|
+
if (!v || typeof v.id !== 'number' || typeof v.key !== 'string')
|
|
135
|
+
return null;
|
|
136
|
+
return {
|
|
137
|
+
id: v.id,
|
|
138
|
+
title: typeof v.title === 'string' ? v.title : '',
|
|
139
|
+
key: v.key.trim(),
|
|
140
|
+
readOnly: v.read_only === true,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
async list(target) {
|
|
145
|
+
const token = await credential(target);
|
|
146
|
+
const keys = [];
|
|
147
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
148
|
+
const { status, json } = await send(token, 'GET', `/repos/${target.repo}/keys?per_page=${PAGE_SIZE}&page=${page}`);
|
|
149
|
+
if (refused(status))
|
|
150
|
+
throw new DeployKeysBlocked('permission', 'listing refused');
|
|
151
|
+
if (status !== 200 || !Array.isArray(json)) {
|
|
152
|
+
throw new DeployKeysBlocked('unreachable', 'listing failed');
|
|
153
|
+
}
|
|
154
|
+
for (const raw of json) {
|
|
155
|
+
const key = asKey(raw);
|
|
156
|
+
if (key)
|
|
157
|
+
keys.push(key);
|
|
158
|
+
}
|
|
159
|
+
if (json.length < PAGE_SIZE)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
return keys;
|
|
163
|
+
},
|
|
164
|
+
async create(target, key) {
|
|
165
|
+
const token = await credential(target);
|
|
166
|
+
const { status, json } = await send(token, 'POST', `/repos/${target.repo}/keys`, {
|
|
167
|
+
title: key.title,
|
|
168
|
+
key: key.key,
|
|
169
|
+
read_only: key.readOnly,
|
|
170
|
+
});
|
|
171
|
+
if (refused(status) && status !== 422) {
|
|
172
|
+
throw new DeployKeysBlocked('permission', 'creating refused');
|
|
173
|
+
}
|
|
174
|
+
const made = status === 201 ? asKey(json) : null;
|
|
175
|
+
if (!made)
|
|
176
|
+
throw new DeployKeysBlocked('unreachable', 'creating failed');
|
|
177
|
+
return made;
|
|
178
|
+
},
|
|
179
|
+
async remove(target, id) {
|
|
180
|
+
const token = await credential(target);
|
|
181
|
+
const { status } = await send(token, 'DELETE', `/repos/${target.repo}/keys/${id}`);
|
|
182
|
+
if (status === 204 || status === 404)
|
|
183
|
+
return;
|
|
184
|
+
if (refused(status))
|
|
185
|
+
throw new DeployKeysBlocked('permission', 'deleting refused');
|
|
186
|
+
throw new DeployKeysBlocked('unreachable', 'deleting failed');
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const sshString = (data) => {
|
|
191
|
+
const length = Buffer.alloc(4);
|
|
192
|
+
length.writeUInt32BE(data.length);
|
|
193
|
+
return Buffer.concat([length, data]);
|
|
194
|
+
};
|
|
195
|
+
const uint32 = (n) => {
|
|
196
|
+
const out = Buffer.alloc(4);
|
|
197
|
+
out.writeUInt32BE(n);
|
|
198
|
+
return out;
|
|
199
|
+
};
|
|
200
|
+
/** A new ed25519 keypair; the private key is an unencrypted `-----BEGIN OPENSSH PRIVATE KEY-----` file, as ssh and git read it. */
|
|
201
|
+
export function generateRepoKeyPair() {
|
|
202
|
+
const { privateKey } = generateKeyPairSync('ed25519');
|
|
203
|
+
const jwk = privateKey.export({ format: 'jwk' });
|
|
204
|
+
const seed = Buffer.from(jwk.d, 'base64url');
|
|
205
|
+
const pub = Buffer.from(jwk.x, 'base64url');
|
|
206
|
+
const type = sshString(Buffer.from('ssh-ed25519'));
|
|
207
|
+
const publicBlob = Buffer.concat([type, sshString(pub)]);
|
|
208
|
+
const check = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(4)));
|
|
209
|
+
const inner = Buffer.concat([
|
|
210
|
+
check,
|
|
211
|
+
check,
|
|
212
|
+
type,
|
|
213
|
+
sshString(pub),
|
|
214
|
+
sshString(Buffer.concat([seed, pub])),
|
|
215
|
+
sshString(Buffer.alloc(0)),
|
|
216
|
+
]);
|
|
217
|
+
const pad = (8 - (inner.length % 8)) % 8;
|
|
218
|
+
const padding = Buffer.from(Array.from({ length: pad }, (_, i) => i + 1));
|
|
219
|
+
const file = Buffer.concat([
|
|
220
|
+
Buffer.from('openssh-key-v1\0'),
|
|
221
|
+
sshString(Buffer.from('none')),
|
|
222
|
+
sshString(Buffer.from('none')),
|
|
223
|
+
sshString(Buffer.alloc(0)),
|
|
224
|
+
uint32(1),
|
|
225
|
+
sshString(publicBlob),
|
|
226
|
+
sshString(Buffer.concat([inner, padding])),
|
|
227
|
+
]);
|
|
228
|
+
const body = file.toString('base64').replace(/(.{70})/g, '$1\n');
|
|
229
|
+
return {
|
|
230
|
+
publicKey: `ssh-ed25519 ${publicBlob.toString('base64')}`,
|
|
231
|
+
privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----\n${body.endsWith('\n') ? body : `${body}\n`}-----END OPENSSH PRIVATE KEY-----\n`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The public key line (`ssh-ed25519 <base64>`) inside an unencrypted OpenSSH private key file, or `null` when the
|
|
236
|
+
* text is not one (so a held value that is not a usable key is replaced, not trusted).
|
|
237
|
+
*/
|
|
238
|
+
export function publicKeyOfPrivate(privateKey) {
|
|
239
|
+
const match = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/.exec(privateKey);
|
|
240
|
+
if (!match)
|
|
241
|
+
return null;
|
|
242
|
+
try {
|
|
243
|
+
const raw = Buffer.from((match[1] ?? '').replace(/\s+/g, ''), 'base64');
|
|
244
|
+
const magic = Buffer.from('openssh-key-v1\0');
|
|
245
|
+
if (!raw.subarray(0, magic.length).equals(magic))
|
|
246
|
+
return null;
|
|
247
|
+
let at = magic.length;
|
|
248
|
+
const readString = () => {
|
|
249
|
+
const length = raw.readUInt32BE(at);
|
|
250
|
+
const out = raw.subarray(at + 4, at + 4 + length);
|
|
251
|
+
if (out.length !== length)
|
|
252
|
+
throw new Error('short');
|
|
253
|
+
at += 4 + length;
|
|
254
|
+
return out;
|
|
255
|
+
};
|
|
256
|
+
if (readString().toString() !== 'none')
|
|
257
|
+
return null;
|
|
258
|
+
if (readString().toString() !== 'none')
|
|
259
|
+
return null;
|
|
260
|
+
readString();
|
|
261
|
+
if (raw.readUInt32BE(at) !== 1)
|
|
262
|
+
return null;
|
|
263
|
+
at += 4;
|
|
264
|
+
const blob = readString();
|
|
265
|
+
if (blob.length === 0)
|
|
266
|
+
return null;
|
|
267
|
+
const typeLength = blob.readUInt32BE(0);
|
|
268
|
+
if (blob.subarray(4, 4 + typeLength).toString() !== 'ssh-ed25519')
|
|
269
|
+
return null;
|
|
270
|
+
// The private section must be whole too: a key cut short, or one whose public half is not the blob's, is not
|
|
271
|
+
// one ssh can use.
|
|
272
|
+
const priv = readString();
|
|
273
|
+
if (priv.length % 8 !== 0 || priv.length < 8)
|
|
274
|
+
return null;
|
|
275
|
+
if (priv.readUInt32BE(0) !== priv.readUInt32BE(4))
|
|
276
|
+
return null;
|
|
277
|
+
let p = 8;
|
|
278
|
+
const privString = () => {
|
|
279
|
+
const length = priv.readUInt32BE(p);
|
|
280
|
+
const out = priv.subarray(p + 4, p + 4 + length);
|
|
281
|
+
if (out.length !== length)
|
|
282
|
+
throw new Error('short');
|
|
283
|
+
p += 4 + length;
|
|
284
|
+
return out;
|
|
285
|
+
};
|
|
286
|
+
if (privString().toString() !== 'ssh-ed25519')
|
|
287
|
+
return null;
|
|
288
|
+
const inner = privString();
|
|
289
|
+
if (!inner.equals(blob.subarray(blob.length - 32)) || inner.length !== 32)
|
|
290
|
+
return null;
|
|
291
|
+
const secret = privString();
|
|
292
|
+
if (secret.length !== 64 || !secret.subarray(32).equals(inner))
|
|
293
|
+
return null;
|
|
294
|
+
return `ssh-ed25519 ${blob.toString('base64')}`;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** True when two `type base64` lines name the same key (a trailing comment is ignored). */
|
|
301
|
+
export const sameSshKey = (a, b) => {
|
|
302
|
+
const [typeA, bodyA] = a.trim().split(/\s+/);
|
|
303
|
+
const [typeB, bodyB] = b.trim().split(/\s+/);
|
|
304
|
+
return Boolean(typeA && bodyA) && typeA === typeB && bodyA === bodyB;
|
|
305
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type HostingGithub } from '../org.js';
|
|
2
|
+
import type { DeclaredOrg } from '../org.js';
|
|
3
|
+
import type { UnmanagedItem } from '../plan.js';
|
|
4
|
+
import type { DraftStep } from './diff.js';
|
|
5
|
+
/** Recorded after the source is made: the installation id it is bound to, so a changed installation plans the step again. */
|
|
6
|
+
export declare const GITHUB_SOURCE_INSTALLATION_SCOPE_KEY = "github_source_installation";
|
|
7
|
+
/** The Coolify private key every company source shares: the platform GitHub App's key, made once. */
|
|
8
|
+
export declare const GITHUB_SOURCE_KEY_NAME = "platform-github-app";
|
|
9
|
+
/** Every Coolify call this module makes is cut off after this long. */
|
|
10
|
+
export declare const GITHUB_SOURCE_TIMEOUT_MS = 20000;
|
|
11
|
+
/** The recorded resource id (`serviceIds` key) of a company's source: the step is keyed by the org id. */
|
|
12
|
+
export declare function githubSourceResourceKey(orgId: string): string;
|
|
13
|
+
/** The Coolify name of a company's GitHub source. */
|
|
14
|
+
export declare function githubSourceName(slug: string): string;
|
|
15
|
+
/** True when an app of this company is built from the company's own account, so it needs the company's source. */
|
|
16
|
+
export declare function needsCompanyGithubSource(org: DeclaredOrg): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* `coolify:github-source` (keyed by the org id): the company's own Coolify GitHub App source, bound to its
|
|
19
|
+
* installation id. Blocked, not planned, while a key of the platform's GitHub App is not held. Planned again
|
|
20
|
+
* when the recorded source is missing or was made for another installation.
|
|
21
|
+
*/
|
|
22
|
+
export declare function planGithubSource(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, keys: {
|
|
23
|
+
store: boolean;
|
|
24
|
+
missing: readonly string[];
|
|
25
|
+
} | undefined): {
|
|
26
|
+
step: DraftStep | null;
|
|
27
|
+
blocked: UnmanagedItem[];
|
|
28
|
+
};
|
|
29
|
+
export interface GithubSourceDeps {
|
|
30
|
+
coolify: {
|
|
31
|
+
raw(method: string, path: string, body?: unknown): Promise<unknown>;
|
|
32
|
+
};
|
|
33
|
+
heldKeys: {
|
|
34
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
35
|
+
};
|
|
36
|
+
/** Registers a value for redaction before anything else can see it. */
|
|
37
|
+
secret(value: string): void;
|
|
38
|
+
/** The step's name, for errors. */
|
|
39
|
+
label: string;
|
|
40
|
+
slug: string;
|
|
41
|
+
github: HostingGithub;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The uuid of the company's Coolify GitHub source: found by name when it is bound to the company's installation,
|
|
46
|
+
* else made from the platform GitHub App's held keys (`PLATFORM_GITHUB_APP_REFS`) and the Coolify private key
|
|
47
|
+
* `platform-github-app` (found, else made). Every Coolify call has a timeout and a failure is fixed text: a
|
|
48
|
+
* Coolify error can echo the request body, which holds the App's secrets. A failure after the private key was
|
|
49
|
+
* made leaves that key (shared, found by name next run) and no source; the source is the last call.
|
|
50
|
+
*/
|
|
51
|
+
export declare function ensureGithubSource(deps: GithubSourceDeps): Promise<{
|
|
52
|
+
uuid: string;
|
|
53
|
+
}>;
|