@lanes-sh/link 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +46 -3
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -55,6 +55,15 @@ export interface SetupNeeds {
|
|
|
55
55
|
* `connect` will never ask for.
|
|
56
56
|
*/
|
|
57
57
|
readonly brokered: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* What `--auth pasted_token` would ask for, where the provider offers it.
|
|
60
|
+
*
|
|
61
|
+
* Separate from `requirements` because it is an alternative rather than a
|
|
62
|
+
* prerequisite: nothing needs it unless the browser route is closed to you.
|
|
63
|
+
* Reported only when it is *not* the chosen route, since when it is chosen it
|
|
64
|
+
* is a requirement above and naming it twice would read as two values.
|
|
65
|
+
*/
|
|
66
|
+
readonly pastedCredential: string | undefined;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
/**
|
|
@@ -64,8 +73,23 @@ export interface SetupNeeds {
|
|
|
64
73
|
* command someone is told to paste and a command the CLI suggests should not be
|
|
65
74
|
* two different sentences.
|
|
66
75
|
*/
|
|
67
|
-
function storeCommand(ref: string, placeholder: string,
|
|
68
|
-
return
|
|
76
|
+
function storeCommand(ref: string, placeholder: string, where: Selection): string {
|
|
77
|
+
return (
|
|
78
|
+
`printf %s "${placeholder}" | lanes link secrets set ${ref}` +
|
|
79
|
+
` --profile ${where.profile} --target ${where.target}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Which store a suggested command should write into.
|
|
85
|
+
*
|
|
86
|
+
* One object rather than two positional strings, because the pair travels
|
|
87
|
+
* together everywhere and a caller that transposed them would produce a command
|
|
88
|
+
* that runs, writes a credential, and puts it somewhere nobody looks.
|
|
89
|
+
*/
|
|
90
|
+
export interface Selection {
|
|
91
|
+
readonly profile: string;
|
|
92
|
+
readonly target: string;
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
/** How the value is spelled, for a ref that several prompts combine into. */
|
|
@@ -80,15 +104,79 @@ function placeholderFor(prompts: readonly SetupPrompt[]): string {
|
|
|
80
104
|
return '<value>';
|
|
81
105
|
}
|
|
82
106
|
|
|
107
|
+
/**
|
|
108
|
+
* What the key route needs, which is the key and nothing else.
|
|
109
|
+
*
|
|
110
|
+
* Never `needsId`, and that is the substantive difference from the block below:
|
|
111
|
+
* the key is shared across every connection of a vendor, so its ref does not
|
|
112
|
+
* derive from a connection id and nothing has to be named before it can be
|
|
113
|
+
* stored. The subject does derive per connection — but it is not a requirement
|
|
114
|
+
* because it cannot be placed ahead of time: it lives inside the pointer that
|
|
115
|
+
* `connect` itself writes, and there is no `secrets set` that would put it
|
|
116
|
+
* there.
|
|
117
|
+
*/
|
|
118
|
+
function assertionRequirements(
|
|
119
|
+
assertion: NonNullable<Extract<ProviderManifest['auth'], { kind: 'oauth' }>['assertion']>,
|
|
120
|
+
where: Selection,
|
|
121
|
+
): SetupNeeds {
|
|
122
|
+
const shared = assertion.setup.prompts.filter((prompt) => prompt.scope === 'shared');
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
requirements: shared.map((prompt) => ({
|
|
126
|
+
ref: assertion.key_ref,
|
|
127
|
+
label: prompt.label,
|
|
128
|
+
secret: prompt.secret,
|
|
129
|
+
scope: 'shared' as const,
|
|
130
|
+
prompts: [prompt.key],
|
|
131
|
+
command: storeCommand(assertion.key_ref, '<value>', where),
|
|
132
|
+
})),
|
|
133
|
+
needsId: false,
|
|
134
|
+
brokered: false,
|
|
135
|
+
pastedCredential: undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether the manifest describes an OAuth client of the operator's own.
|
|
141
|
+
*
|
|
142
|
+
* `defineProvider` permits a broker, or a shipped client id, with no client
|
|
143
|
+
* prompts — a provider with no bring-your-own path is a legal thing to be. Every
|
|
144
|
+
* surface that offers that path has to ask this first, or it offers a route with
|
|
145
|
+
* nothing behind it: the chooser, the client resolver, and the setup plan.
|
|
146
|
+
*
|
|
147
|
+
* Here rather than in any of the three because it was already written twice in
|
|
148
|
+
* two different components under two different names, and this change would
|
|
149
|
+
* have made it three.
|
|
150
|
+
*/
|
|
151
|
+
export function hasOwnClientPath(manifest: ProviderManifest): boolean {
|
|
152
|
+
return (manifest.setup?.prompts ?? []).some((prompt) => prompt.scope === 'shared');
|
|
153
|
+
}
|
|
154
|
+
|
|
83
155
|
export function setupRequirements(
|
|
84
156
|
manifest: ProviderManifest,
|
|
85
157
|
connectionId: string | undefined,
|
|
86
|
-
|
|
158
|
+
where: Selection,
|
|
87
159
|
options: {
|
|
88
160
|
/** `oauth_apps` entries this profile declares — the clients that are its own. */
|
|
89
161
|
readonly ownClients?: readonly string[];
|
|
162
|
+
/**
|
|
163
|
+
* Which way in, for a provider offering two.
|
|
164
|
+
*
|
|
165
|
+
* Defaults to the browser flow, which is every provider's only route until
|
|
166
|
+
* one declares `auth.assertion` — so an omitted value reports exactly what
|
|
167
|
+
* it always reported. Passed by `connect` once the operator has chosen,
|
|
168
|
+
* because the two methods need different things and reporting the union
|
|
169
|
+
* would tell someone to store a client they are not going to use.
|
|
170
|
+
*/
|
|
171
|
+
readonly method?: 'oauth' | 'assertion' | 'pasted';
|
|
90
172
|
} = {},
|
|
91
173
|
): SetupNeeds {
|
|
174
|
+
const assertion = manifest.auth.kind === 'oauth' ? manifest.auth.assertion : undefined;
|
|
175
|
+
|
|
176
|
+
if (options.method === 'assertion' && assertion) {
|
|
177
|
+
return assertionRequirements(assertion, where);
|
|
178
|
+
}
|
|
179
|
+
|
|
92
180
|
const prompts = manifest.setup?.prompts ?? [];
|
|
93
181
|
|
|
94
182
|
// A shared prompt exists to collect a client the operator registers. When a
|
|
@@ -101,7 +189,20 @@ export function setupRequirements(
|
|
|
101
189
|
!(manifest.auth.app !== undefined && (options.ownClients ?? []).includes(manifest.auth.app));
|
|
102
190
|
|
|
103
191
|
const shared = brokered ? [] : prompts.filter((prompt) => prompt.scope === 'shared');
|
|
104
|
-
|
|
192
|
+
|
|
193
|
+
// An OAuth provider's per-connection prompt belongs to one route, not to the
|
|
194
|
+
// provider — it is what `--auth pasted_token` asks for, and nothing the
|
|
195
|
+
// browser route ever needs. Reporting it either way would put a
|
|
196
|
+
// mandatory-looking field in front of somebody whose whole path is a browser
|
|
197
|
+
// round trip, which is the same mistake `brokered` avoids one line above.
|
|
198
|
+
//
|
|
199
|
+
// No OAuth manifest had one of these until Slack kept its pasted token as the
|
|
200
|
+
// way past a workspace that has not approved the Lanes app.
|
|
201
|
+
const connectionPrompts = prompts.filter((prompt) => prompt.scope === 'connection');
|
|
202
|
+
const routed = manifest.auth.kind !== 'oauth' || options.method === 'pasted';
|
|
203
|
+
|
|
204
|
+
const pasted = routed ? [] : connectionPrompts;
|
|
205
|
+
const perConnection = routed ? connectionPrompts : [];
|
|
105
206
|
|
|
106
207
|
const requirements: SetupRequirement[] = [];
|
|
107
208
|
|
|
@@ -115,7 +216,7 @@ export function setupRequirements(
|
|
|
115
216
|
secret: prompt.secret,
|
|
116
217
|
scope: 'shared',
|
|
117
218
|
prompts: [prompt.key],
|
|
118
|
-
command: storeCommand(prompt.credential_ref, '<value>',
|
|
219
|
+
command: storeCommand(prompt.credential_ref, '<value>', where),
|
|
119
220
|
});
|
|
120
221
|
}
|
|
121
222
|
|
|
@@ -133,7 +234,7 @@ export function setupRequirements(
|
|
|
133
234
|
secret: perConnection.some((prompt) => prompt.secret),
|
|
134
235
|
scope: 'connection',
|
|
135
236
|
prompts: perConnection.map((prompt) => prompt.key),
|
|
136
|
-
command: storeCommand(ref, placeholderFor(perConnection),
|
|
237
|
+
command: storeCommand(ref, placeholderFor(perConnection), where),
|
|
137
238
|
});
|
|
138
239
|
}
|
|
139
240
|
}
|
|
@@ -142,5 +243,7 @@ export function setupRequirements(
|
|
|
142
243
|
requirements,
|
|
143
244
|
needsId: perConnection.length > 0 && connectionId === undefined,
|
|
144
245
|
brokered,
|
|
246
|
+
pastedCredential:
|
|
247
|
+
pasted.length > 0 ? pasted.map((prompt) => prompt.label).join(', then ') : undefined,
|
|
145
248
|
};
|
|
146
249
|
}
|
|
@@ -166,7 +166,16 @@ const BY_EXTENSION: Record<string, string> = {
|
|
|
166
166
|
'.yml': 'application/yaml',
|
|
167
167
|
};
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Exported so `github.ts` answers identically.
|
|
171
|
+
*
|
|
172
|
+
* That adapter has nowhere to *store* a content type — a sidecar there would be
|
|
173
|
+
* a file in the owner's own repository, listed beside their entries — so the
|
|
174
|
+
* extension is all it has. Sharing the map means a `.md` written locally and
|
|
175
|
+
* the same `.md` in a repository do not report different types, which is what
|
|
176
|
+
* `#stores/blobs/conformance.ts` exists to hold.
|
|
177
|
+
*/
|
|
178
|
+
export function inferContentType(path: string): string | undefined {
|
|
170
179
|
const dot = path.lastIndexOf('.');
|
|
171
180
|
return dot === -1 ? undefined : BY_EXTENSION[path.slice(dot).toLowerCase()];
|
|
172
181
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire, for the two files above it.
|
|
3
|
+
*
|
|
4
|
+
* `github-repo.ts` holds the state — which commit the branch is on, what is in
|
|
5
|
+
* its tree, which blobs have been read — and `github-commit.ts` holds the one
|
|
6
|
+
* operation that assembles several files into a single commit. What is left
|
|
7
|
+
* here is everything neither of them should have to know twice: the host, the
|
|
8
|
+
* headers, base64, path encoding, and what a failure means.
|
|
9
|
+
*
|
|
10
|
+
* `fetch` and no client library, the same argument `gcs.ts` and
|
|
11
|
+
* `gcp-secret-manager.ts` make: a repository holding live refresh tokens does
|
|
12
|
+
* not take on a transitive dependency tree to save some URL building.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Declared here rather than imported, for the reason `gcs.ts` gives: this
|
|
17
|
+
* component may not import `#auth`, and `typeof globalThis.fetch` under Bun's
|
|
18
|
+
* types carries a `preconnect` a test double would have to stub.
|
|
19
|
+
*/
|
|
20
|
+
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
21
|
+
|
|
22
|
+
export const API = 'https://api.github.com';
|
|
23
|
+
|
|
24
|
+
/** Every request declares the version, as GitHub's own documentation does. */
|
|
25
|
+
const VERSION = '2022-11-28';
|
|
26
|
+
|
|
27
|
+
export function githubHeaders(token: string, init: RequestInit): Record<string, string> {
|
|
28
|
+
const headers: Record<string, string> = {
|
|
29
|
+
accept: 'application/vnd.github+json',
|
|
30
|
+
authorization: `Bearer ${token}`,
|
|
31
|
+
'x-github-api-version': VERSION,
|
|
32
|
+
...((init.headers ?? {}) as Record<string, string>),
|
|
33
|
+
};
|
|
34
|
+
if (init.body !== undefined) headers['content-type'] = 'application/json';
|
|
35
|
+
return headers;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Percent-encode each segment, leaving the slashes that address a directory. */
|
|
39
|
+
export function encodePath(path: string): string {
|
|
40
|
+
return path.split('/').map(encodeURIComponent).join('/');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function base64(data: Uint8Array): string {
|
|
44
|
+
let binary = '';
|
|
45
|
+
for (const byte of data) binary += String.fromCharCode(byte);
|
|
46
|
+
return btoa(binary);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class GithubApiError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
message: string,
|
|
52
|
+
readonly status: number,
|
|
53
|
+
) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'GithubApiError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A failure that says what was being attempted and, where GitHub's own message
|
|
61
|
+
* does not, what to do about it.
|
|
62
|
+
*
|
|
63
|
+
* Three are worth naming because they are the three an operator actually hits:
|
|
64
|
+
* a token that has expired or was never granted this repository, the rate
|
|
65
|
+
* limit, and a repository whose name is right and whose visibility is not.
|
|
66
|
+
* GitHub answers the last two of those with a 404 by design — it does not
|
|
67
|
+
* confirm that a repository you cannot see exists — so the message has to offer
|
|
68
|
+
* the causes rather than assert one.
|
|
69
|
+
*/
|
|
70
|
+
export async function failure(what: string, response: Response): Promise<GithubApiError> {
|
|
71
|
+
const body = await response.text().catch(() => '');
|
|
72
|
+
const detail = body.slice(0, 300);
|
|
73
|
+
|
|
74
|
+
if (response.status === 401) {
|
|
75
|
+
return new GithubApiError(
|
|
76
|
+
`GitHub refused the token while trying to ${what} (401). It has expired, been revoked, or ` +
|
|
77
|
+
'was never valid. Generate another and run: lanes link knowledge use github --repo <owner/name>',
|
|
78
|
+
401,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
|
|
83
|
+
const reset = Number(response.headers.get('x-ratelimit-reset') ?? 0);
|
|
84
|
+
const when = reset > 0 ? new Date(reset * 1000).toISOString() : 'shortly';
|
|
85
|
+
return new GithubApiError(
|
|
86
|
+
`GitHub's rate limit is spent, so this could not ${what}. It resets at ${when}. ` +
|
|
87
|
+
"Keeping memory and skills in a repository makes that limit one of this endpoint's own — " +
|
|
88
|
+
'ADR-041 says so plainly, and it is the cost of the arrangement rather than a fault.',
|
|
89
|
+
403,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (response.status === 403 || response.status === 404) {
|
|
94
|
+
return new GithubApiError(
|
|
95
|
+
`GitHub would not let this token ${what} (${response.status}). The usual causes are a token ` +
|
|
96
|
+
'that was not granted this repository, a fine-grained token still waiting on an ' +
|
|
97
|
+
`organisation owner's approval, or Contents permission set to read rather than write. ${detail}`,
|
|
98
|
+
response.status,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return new GithubApiError(
|
|
103
|
+
`GitHub failed to ${what} (${response.status}). ${detail}`,
|
|
104
|
+
response.status,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { base64 } from './github-api.ts';
|
|
2
|
+
import type { GithubRepository } from './github-repo.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Many files, and many deletions, as **one** commit.
|
|
6
|
+
*
|
|
7
|
+
* `writeFile` is one commit per file, which is right for a single write and
|
|
8
|
+
* wrong for a migration: two hundred commits each reading "store memory entry"
|
|
9
|
+
* is not a history anybody opens, and it is two hundred round trips against a
|
|
10
|
+
* limit this endpoint now shares. This goes through the Git Data API instead —
|
|
11
|
+
* blobs, a tree, a commit, then move the ref — which is what `git push` does
|
|
12
|
+
* and produces the same object graph.
|
|
13
|
+
*
|
|
14
|
+
* Its own file because it is the one operation that does not fit the
|
|
15
|
+
* one-request-one-answer shape of everything in `github-repo.ts`, and because
|
|
16
|
+
* it needs none of that file's private state: it works entirely through the
|
|
17
|
+
* repository's public surface, so the split costs nothing.
|
|
18
|
+
*
|
|
19
|
+
* **The empty repository is the ordinary case here, not an edge one.** Somebody
|
|
20
|
+
* following the setup steps creates a repository for this and migrates into it
|
|
21
|
+
* immediately, so there is no parent commit and no base tree, and the ref has
|
|
22
|
+
* to be created rather than moved.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface CommitFile {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly data: Uint8Array;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The new commit sha, or null when there was nothing to commit. */
|
|
31
|
+
export async function commitFiles(
|
|
32
|
+
repository: GithubRepository,
|
|
33
|
+
files: readonly CommitFile[],
|
|
34
|
+
deletes: readonly string[],
|
|
35
|
+
message: string,
|
|
36
|
+
): Promise<string | null> {
|
|
37
|
+
if (files.length === 0 && deletes.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
const branch = await repository.branch();
|
|
40
|
+
// Forced: a migration is the one place where reading a two-second-old head
|
|
41
|
+
// could mean committing against a parent that has already moved.
|
|
42
|
+
const head = await repository.head(true);
|
|
43
|
+
|
|
44
|
+
const blobs = await Promise.all(
|
|
45
|
+
files.map(async (file) => ({
|
|
46
|
+
path: file.path,
|
|
47
|
+
sha: (
|
|
48
|
+
await repository.json<{ sha: string }>(
|
|
49
|
+
`/repos/${repository.repo}/git/blobs`,
|
|
50
|
+
{
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: JSON.stringify({ content: base64(file.data), encoding: 'base64' }),
|
|
53
|
+
},
|
|
54
|
+
`upload "${file.path}" to ${repository.repo}`,
|
|
55
|
+
)
|
|
56
|
+
).sha,
|
|
57
|
+
})),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const tree = await repository.json<{ sha: string }>(
|
|
61
|
+
`/repos/${repository.repo}/git/trees`,
|
|
62
|
+
{
|
|
63
|
+
method: 'POST',
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
...(head ? { base_tree: head.commit } : {}),
|
|
66
|
+
tree: [
|
|
67
|
+
...blobs.map((blob) => ({ path: blob.path, mode: '100644', type: 'blob', sha: blob.sha })),
|
|
68
|
+
// A null sha is how the Git Data API spells "not in this tree".
|
|
69
|
+
...deletes.map((path) => ({ path, mode: '100644', type: 'blob', sha: null })),
|
|
70
|
+
],
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
`build a tree for ${repository.repo}`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const commit = await repository.json<{ sha: string }>(
|
|
77
|
+
`/repos/${repository.repo}/git/commits`,
|
|
78
|
+
{
|
|
79
|
+
method: 'POST',
|
|
80
|
+
body: JSON.stringify({ message, tree: tree.sha, parents: head ? [head.commit] : [] }),
|
|
81
|
+
},
|
|
82
|
+
`commit to ${repository.repo}`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Create when the branch has no commits, move when it does. A `PATCH` on a
|
|
86
|
+
// ref that does not exist answers 422, which reads as a permissions problem
|
|
87
|
+
// and is not one.
|
|
88
|
+
await repository.json(
|
|
89
|
+
head
|
|
90
|
+
? `/repos/${repository.repo}/git/refs/heads/${encodeURIComponent(branch)}`
|
|
91
|
+
: `/repos/${repository.repo}/git/refs`,
|
|
92
|
+
{
|
|
93
|
+
method: head ? 'PATCH' : 'POST',
|
|
94
|
+
body: JSON.stringify(
|
|
95
|
+
head ? { sha: commit.sha } : { ref: `refs/heads/${branch}`, sha: commit.sha },
|
|
96
|
+
),
|
|
97
|
+
},
|
|
98
|
+
`move "${branch}" in ${repository.repo}`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
repository.invalidate();
|
|
102
|
+
return commit.sha;
|
|
103
|
+
}
|