@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.
Files changed (130) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +56 -158
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/serve.ts +3 -0
  33. package/src/cli/commands/operate/token.ts +1 -1
  34. package/src/cli/commands/operate.ts +2 -0
  35. package/src/cli/commands/profile/declare.ts +154 -0
  36. package/src/cli/commands/profile/removal.ts +17 -0
  37. package/src/cli/commands/profile.ts +83 -35
  38. package/src/cli/commands/setup.ts +22 -6
  39. package/src/cli/commands/target.ts +65 -83
  40. package/src/cli/config-edit.ts +48 -144
  41. package/src/cli/config-repair.ts +186 -0
  42. package/src/cli/dashboard-page.ts +284 -0
  43. package/src/cli/dashboard-shell.ts +125 -0
  44. package/src/cli/identity.ts +12 -1
  45. package/src/cli/main.ts +68 -4
  46. package/src/cli/oauth-callback.ts +187 -0
  47. package/src/cli/oauth-exchange.ts +57 -15
  48. package/src/cli/oauth.ts +67 -177
  49. package/src/cli/output.ts +21 -5
  50. package/src/cli/provider-marks.ts +45 -0
  51. package/src/cli/runtime/open.ts +67 -49
  52. package/src/cli/runtime/registry.ts +60 -2
  53. package/src/cli/runtime/select.ts +26 -13
  54. package/src/cli/runtime/vault.ts +61 -0
  55. package/src/cli/runtime.ts +2 -1
  56. package/src/cli/selection.ts +357 -0
  57. package/src/cli/usage.ts +32 -9
  58. package/src/connectivity/auth/README.md +7 -1
  59. package/src/connectivity/auth/basic/index.ts +1 -1
  60. package/src/connectivity/auth/index.ts +14 -0
  61. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  62. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  63. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  64. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  65. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  66. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  67. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  68. package/src/connectivity/auth/resolve.ts +1 -1
  69. package/src/connectivity/auth/token.ts +11 -0
  70. package/src/connectivity/index.ts +2 -0
  71. package/src/connectivity/manifest/auth.ts +99 -2
  72. package/src/connectivity/manifest/identity.ts +12 -0
  73. package/src/connectivity/manifest/index.ts +3 -1
  74. package/src/connectivity/manifest/provider.ts +37 -8
  75. package/src/connectivity/manifest/requirements.ts +109 -6
  76. package/src/deployments/adapters/filesystem.ts +10 -1
  77. package/src/deployments/adapters/github-api.ts +106 -0
  78. package/src/deployments/adapters/github-commit.ts +103 -0
  79. package/src/deployments/adapters/github-repo.ts +356 -0
  80. package/src/deployments/adapters/github-testing.ts +258 -0
  81. package/src/deployments/adapters/github.ts +125 -0
  82. package/src/deployments/deploy.ts +48 -19
  83. package/src/deployments/driver.ts +8 -1
  84. package/src/deployments/gcp/driver.ts +3 -1
  85. package/src/deployments/knowledge.ts +119 -0
  86. package/src/deployments/prepare.ts +2 -2
  87. package/src/deployments/servable.ts +81 -0
  88. package/src/deployments/target.ts +3 -2
  89. package/src/deployments/upload.ts +2 -1
  90. package/src/dispatch/dispatch.ts +1 -1
  91. package/src/profile/identity.ts +60 -0
  92. package/src/profile/index.ts +15 -5
  93. package/src/profile/knowledge.ts +124 -0
  94. package/src/profile/load.ts +17 -5
  95. package/src/profile/primitives.ts +24 -1
  96. package/src/profile/schema.ts +46 -3
  97. package/src/profile/targets.ts +74 -114
  98. package/src/profile/workspace.ts +71 -84
  99. package/src/providers/google/calendar/index.ts +2 -0
  100. package/src/providers/google/contacts/index.ts +2 -0
  101. package/src/providers/google/docs/index.ts +2 -0
  102. package/src/providers/google/drive/index.ts +2 -0
  103. package/src/providers/google/gmail/index.ts +2 -0
  104. package/src/providers/google/gmail-imap/index.ts +125 -0
  105. package/src/providers/google/index.ts +2 -1
  106. package/src/providers/google/shared/oauth.ts +18 -6
  107. package/src/providers/google/shared/service-account.ts +110 -0
  108. package/src/providers/google/shared/setup.ts +5 -2
  109. package/src/providers/google/sheets/index.ts +2 -0
  110. package/src/providers/google/tasks/index.ts +2 -0
  111. package/src/providers/identity/provider.ts +166 -0
  112. package/src/providers/index.ts +3 -0
  113. package/src/providers/owner.ts +10 -2
  114. package/src/providers/scopes.ts +2 -0
  115. package/src/providers/setup/plan.ts +31 -9
  116. package/src/providers/setup/provider.ts +23 -0
  117. package/src/providers/slack/index.ts +81 -33
  118. package/src/providers/slack/oauth.ts +103 -0
  119. package/src/providers/slack/scopes.ts +37 -0
  120. package/src/server/container.ts +18 -1
  121. package/src/server/cors.ts +252 -0
  122. package/src/server/dashboard.ts +208 -0
  123. package/src/server/endpoint.ts +45 -1
  124. package/src/server/generations.ts +11 -2
  125. package/src/server/harness.ts +7 -0
  126. package/src/server/index.ts +45 -6
  127. package/src/server/mcp/index.ts +1 -0
  128. package/src/server/mcp/instructions.ts +28 -1
  129. package/src/server/mcp/visibility.ts +33 -0
  130. 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, profile: string): string {
68
- return `printf %s "${placeholder}" | lanes link secrets set ${ref} --profile ${profile}`;
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
- profile: string,
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
- const perConnection = prompts.filter((prompt) => prompt.scope === 'connection');
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>', profile),
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), profile),
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
- function inferContentType(path: string): string | undefined {
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
+ }