@crombolo/cli 0.14.1 → 0.15.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/bin/crombolo.ts +4 -2767
- package/oclif.manifest.json +6710 -0
- package/package.json +58 -7
- package/src/base.ts +129 -0
- package/src/classify.ts +119 -0
- package/src/clispec.ts +163 -0
- package/src/commands/admin/reset-password.ts +53 -0
- package/src/commands/assets/index.ts +6 -0
- package/src/commands/assets/list.ts +27 -0
- package/src/commands/assets/new.ts +42 -0
- package/src/commands/bundle/purge.ts +71 -0
- package/src/commands/components/check.ts +58 -0
- package/src/commands/components/index.ts +6 -0
- package/src/commands/components/list.ts +40 -0
- package/src/commands/components/new.ts +49 -0
- package/src/commands/content/check.ts +102 -0
- package/src/commands/content/export.ts +116 -0
- package/src/commands/content/import.ts +109 -0
- package/src/commands/content/publish.ts +21 -0
- package/src/commands/content/revert.ts +137 -0
- package/src/commands/content/runs.ts +62 -0
- package/src/commands/content/unpublish.ts +21 -0
- package/src/commands/dictionary/export.ts +49 -0
- package/src/commands/dictionary/import.ts +46 -0
- package/src/commands/domains/apply.ts +23 -0
- package/src/commands/domains/clear.ts +27 -0
- package/src/commands/domains/index.ts +6 -0
- package/src/commands/domains/list.ts +86 -0
- package/src/commands/domains/set.ts +55 -0
- package/src/commands/domains/undo.ts +29 -0
- package/src/commands/export.ts +100 -0
- package/src/commands/generate.ts +33 -0
- package/src/commands/import/index.ts +175 -0
- package/src/commands/import/umbraco/apply.ts +128 -0
- package/src/commands/import/umbraco/report.ts +56 -0
- package/src/commands/init.ts +76 -0
- package/src/commands/login.ts +38 -0
- package/src/commands/logout.ts +36 -0
- package/src/commands/maintenance/pause.ts +29 -0
- package/src/commands/maintenance/resume.ts +20 -0
- package/src/commands/maintenance/status.ts +34 -0
- package/src/commands/media/fetch.ts +62 -0
- package/src/commands/pull.ts +210 -0
- package/src/commands/push.ts +108 -0
- package/src/commands/remote/add.ts +78 -0
- package/src/commands/remote/job.ts +53 -0
- package/src/commands/remote/list.ts +41 -0
- package/src/commands/remote/remove.ts +26 -0
- package/src/commands/schema.ts +32 -0
- package/src/commands/start.ts +64 -0
- package/src/commands/status.ts +103 -0
- package/src/commands/types/add-property.ts +116 -0
- package/src/commands/types/check.ts +88 -0
- package/src/commands/types/export.ts +32 -0
- package/src/commands/types/new.ts +116 -0
- package/src/commands/types/purge.ts +65 -0
- package/src/commands/types/rewrite.ts +30 -0
- package/src/commands/types/sync.ts +66 -0
- package/src/commands/upgrade/check.ts +69 -0
- package/src/commands/upgrade/index.ts +120 -0
- package/src/commands/upgrade/ledger.ts +55 -0
- package/src/commands/whoami.ts +41 -0
- package/src/errors.ts +90 -0
- package/src/main.ts +23 -0
- package/src/manifest.ts +20 -0
- package/src/reference.ts +118 -0
- package/src/remote/client.ts +297 -0
- package/src/remote/remotes-file.ts +81 -0
- package/src/remote/transfer.ts +353 -0
- package/src/support/components.ts +7 -0
- package/src/support/describe.ts +179 -0
- package/src/support/domains.ts +89 -0
- package/src/support/flags.ts +157 -0
- package/src/support/prompt.ts +126 -0
- package/src/support/publish.ts +142 -0
- package/src/support/remote.ts +58 -0
- package/src/support/serve.ts +460 -0
- package/src/support/site.ts +26 -0
- package/src/support/types.ts +112 -0
- package/src/support/umbraco.ts +86 -0
- package/templates/basic/bundle/content/bundle.json +1 -1
- package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- package/templates/starter/bundle/package.json +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's side of the remote protocol (story 108, `docs/remote-protocol.md`):
|
|
3
|
+
* discovery, signing in by the device grant, and the API — jobs, downloads,
|
|
4
|
+
* uploads and media — under a sign-in kept in the keychain and refreshed as it
|
|
5
|
+
* runs out.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { createWriteStream } from 'node:fs'
|
|
9
|
+
import { freshAccessToken, type Keychain, readSignIn, saveSignIn } from '@crombolo/server'
|
|
10
|
+
|
|
11
|
+
export const PROTOCOL_VERSION = '1'
|
|
12
|
+
|
|
13
|
+
export interface Discovery {
|
|
14
|
+
protocol: string
|
|
15
|
+
versions: string[]
|
|
16
|
+
name: string
|
|
17
|
+
api: string
|
|
18
|
+
auth: { deviceAuthorization: string; token: string; clientId: string; grantType: string }
|
|
19
|
+
capabilities: string[]
|
|
20
|
+
environments: Array<{ id: string; name: string; protected: boolean }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Problem {
|
|
24
|
+
status: number
|
|
25
|
+
code: string
|
|
26
|
+
title: string
|
|
27
|
+
detail?: string
|
|
28
|
+
items?: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class RemoteError extends Error {
|
|
32
|
+
constructor(readonly problem: Problem) {
|
|
33
|
+
super(problem.detail ? `${problem.title}: ${problem.detail}` : problem.title)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Job {
|
|
38
|
+
id: string
|
|
39
|
+
kind: 'pull' | 'push'
|
|
40
|
+
environment: string
|
|
41
|
+
state: 'queued' | 'running' | 'approval-required' | 'done' | 'failed' | 'refused'
|
|
42
|
+
stage?: string | null
|
|
43
|
+
progress?: number | null
|
|
44
|
+
pollAfter?: number
|
|
45
|
+
approvalUrl?: string
|
|
46
|
+
download?: { url: string; expiresAt: string; size: number | null; sha256: string | null }
|
|
47
|
+
problem?: Problem
|
|
48
|
+
runId?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reads a remote's discovery document; refuses a site that is not a remote, or one speaking only other versions. */
|
|
52
|
+
export async function discover(url: string, http: typeof fetch = fetch): Promise<Discovery> {
|
|
53
|
+
const origin = new URL(url).origin
|
|
54
|
+
let response: Response
|
|
55
|
+
try {
|
|
56
|
+
response = await http(`${origin}/.well-known/crombolo-remote`)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new RemoteError({
|
|
59
|
+
status: 0,
|
|
60
|
+
code: 'unreachable',
|
|
61
|
+
title: `${origin} could not be reached: ${error instanceof Error ? error.message : error}`,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
const notARemote = () =>
|
|
65
|
+
new RemoteError({
|
|
66
|
+
status: 404,
|
|
67
|
+
code: 'not-a-remote',
|
|
68
|
+
title: `${origin} is not a crombolo remote.`,
|
|
69
|
+
})
|
|
70
|
+
if (response.status === 404) throw notARemote()
|
|
71
|
+
if (!response.ok) throw await problemOf(response)
|
|
72
|
+
const body = (await response.json()) as Discovery
|
|
73
|
+
if (body.protocol !== 'crombolo-remote') throw notARemote()
|
|
74
|
+
if (!body.versions?.includes(PROTOCOL_VERSION))
|
|
75
|
+
throw new RemoteError({
|
|
76
|
+
status: 0,
|
|
77
|
+
code: 'protocol-version',
|
|
78
|
+
title: `${origin} speaks protocol ${body.versions?.join(', ') || 'none'}; this crombolo speaks ${PROTOCOL_VERSION}.`,
|
|
79
|
+
})
|
|
80
|
+
return body
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Signs in by the device grant (RFC 8628): `onCode` shows the person the code
|
|
85
|
+
* and the page to approve it on, then this polls until they decide. The tokens
|
|
86
|
+
* go into the keychain.
|
|
87
|
+
*/
|
|
88
|
+
export async function signIn(
|
|
89
|
+
discovery: Discovery,
|
|
90
|
+
keychain: Keychain,
|
|
91
|
+
options: {
|
|
92
|
+
clientName: string
|
|
93
|
+
onCode(code: string, url: string): void | Promise<void>
|
|
94
|
+
http?: typeof fetch
|
|
95
|
+
sleep?: (ms: number) => Promise<void>
|
|
96
|
+
},
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const http = options.http ?? fetch
|
|
99
|
+
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms))
|
|
100
|
+
const form = (url: string, fields: Record<string, string>) =>
|
|
101
|
+
http(url, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
104
|
+
body: new URLSearchParams(fields).toString(),
|
|
105
|
+
})
|
|
106
|
+
const started = await form(discovery.auth.deviceAuthorization, {
|
|
107
|
+
client_id: discovery.auth.clientId,
|
|
108
|
+
client_name: options.clientName,
|
|
109
|
+
})
|
|
110
|
+
if (!started.ok) throw new Error(`The remote would not start a sign-in (${started.status}).`)
|
|
111
|
+
const codes = (await started.json()) as {
|
|
112
|
+
device_code: string
|
|
113
|
+
user_code: string
|
|
114
|
+
verification_uri: string
|
|
115
|
+
verification_uri_complete?: string
|
|
116
|
+
expires_in: number
|
|
117
|
+
interval: number
|
|
118
|
+
}
|
|
119
|
+
await options.onCode(codes.user_code, codes.verification_uri_complete ?? codes.verification_uri)
|
|
120
|
+
let interval = codes.interval * 1000
|
|
121
|
+
const deadline = Date.now() + codes.expires_in * 1000
|
|
122
|
+
for (;;) {
|
|
123
|
+
await sleep(interval)
|
|
124
|
+
const polled = await form(discovery.auth.token, {
|
|
125
|
+
grant_type: discovery.auth.grantType,
|
|
126
|
+
device_code: codes.device_code,
|
|
127
|
+
client_id: discovery.auth.clientId,
|
|
128
|
+
})
|
|
129
|
+
const body = (await polled.json().catch(() => ({}))) as {
|
|
130
|
+
error?: string
|
|
131
|
+
access_token?: string
|
|
132
|
+
refresh_token?: string
|
|
133
|
+
expires_in?: number
|
|
134
|
+
}
|
|
135
|
+
if (polled.ok && body.access_token) {
|
|
136
|
+
await saveSignIn(keychain, discovery.api, {
|
|
137
|
+
accessToken: body.access_token,
|
|
138
|
+
refreshToken: body.refresh_token ?? '',
|
|
139
|
+
expiresAt: Date.now() + (body.expires_in ?? 0) * 1000,
|
|
140
|
+
tokenUrl: discovery.auth.token,
|
|
141
|
+
clientId: discovery.auth.clientId,
|
|
142
|
+
})
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (body.error === 'slow_down') interval += 5000
|
|
146
|
+
else if (body.error === 'access_denied') throw new Error('The sign-in was declined.')
|
|
147
|
+
else if (body.error === 'expired_token' || Date.now() > deadline)
|
|
148
|
+
throw new Error('The code expired before it was approved. Sign in again.')
|
|
149
|
+
else if (body.error !== 'authorization_pending')
|
|
150
|
+
throw new Error(`The sign-in failed: ${body.error ?? polled.status}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The protocol's API for one remote, as the signed-in person. */
|
|
155
|
+
export class RemoteClient {
|
|
156
|
+
readonly discovery: Discovery
|
|
157
|
+
#keychain: Keychain
|
|
158
|
+
#http: typeof fetch
|
|
159
|
+
|
|
160
|
+
constructor(discovery: Discovery, keychain: Keychain, http: typeof fetch = fetch) {
|
|
161
|
+
this.discovery = discovery
|
|
162
|
+
this.#keychain = keychain
|
|
163
|
+
this.#http = http
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async signedIn(): Promise<boolean> {
|
|
167
|
+
return (await readSignIn(this.#keychain, this.discovery.api)) !== undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One authorised request; a 401 refreshes the sign-in once and tries again. */
|
|
171
|
+
async call(url: string, init: RequestInit = {}): Promise<Response> {
|
|
172
|
+
const attempt = async (force: boolean) => {
|
|
173
|
+
const token = await freshAccessToken(this.#keychain, this.discovery.api, this.#http, force)
|
|
174
|
+
if (!token)
|
|
175
|
+
throw new RemoteError({
|
|
176
|
+
status: 401,
|
|
177
|
+
code: 'sign-in-required',
|
|
178
|
+
title: `Not signed in to ${new URL(this.discovery.api).origin}`,
|
|
179
|
+
detail: 'Run `crombolo login` first.',
|
|
180
|
+
})
|
|
181
|
+
const headers = new Headers(init.headers)
|
|
182
|
+
headers.set('authorization', `Bearer ${token}`)
|
|
183
|
+
return this.#http(url, { ...init, headers })
|
|
184
|
+
}
|
|
185
|
+
const first = await attempt(false)
|
|
186
|
+
if (first.status !== 401) return first
|
|
187
|
+
return attempt(true)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async api(path: string, init: RequestInit = {}): Promise<Response> {
|
|
191
|
+
return this.call(`${this.discovery.api}/${path}`, init)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async json<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
195
|
+
const response = await this.api(path, init)
|
|
196
|
+
if (!response.ok) throw await problemOf(response)
|
|
197
|
+
return (await response.json()) as T
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
post<T>(path: string, body: unknown): Promise<T> {
|
|
201
|
+
return this.json<T>(path, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'content-type': 'application/json' },
|
|
204
|
+
body: JSON.stringify(body),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
environments() {
|
|
209
|
+
return this.json<
|
|
210
|
+
Array<{
|
|
211
|
+
id: string
|
|
212
|
+
name: string
|
|
213
|
+
protected: boolean
|
|
214
|
+
may: { pull: boolean; pullFull: boolean; push: boolean }
|
|
215
|
+
}>
|
|
216
|
+
>('environments')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Waits for a job to stop moving, reporting each change; `onApproval` hears it once. */
|
|
220
|
+
async settle(
|
|
221
|
+
id: string,
|
|
222
|
+
options: {
|
|
223
|
+
onProgress?(job: Job): void
|
|
224
|
+
onApproval?(job: Job): void | Promise<void>
|
|
225
|
+
timeoutMs?: number
|
|
226
|
+
sleep?: (ms: number) => Promise<void>
|
|
227
|
+
} = {},
|
|
228
|
+
): Promise<Job> {
|
|
229
|
+
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms))
|
|
230
|
+
const deadline = Date.now() + (options.timeoutMs ?? 30 * 60_000)
|
|
231
|
+
let announced = false
|
|
232
|
+
let last = ''
|
|
233
|
+
for (;;) {
|
|
234
|
+
const job = await this.json<Job>(`jobs/${id}`)
|
|
235
|
+
const seen = `${job.state}|${job.stage ?? ''}|${job.progress ?? ''}`
|
|
236
|
+
if (seen !== last) options.onProgress?.(job)
|
|
237
|
+
last = seen
|
|
238
|
+
if (job.state === 'approval-required' && !announced) {
|
|
239
|
+
announced = true
|
|
240
|
+
await options.onApproval?.(job)
|
|
241
|
+
}
|
|
242
|
+
if (job.state !== 'queued' && job.state !== 'running' && job.state !== 'approval-required')
|
|
243
|
+
return job
|
|
244
|
+
if (Date.now() > deadline) throw new TimedOut(job)
|
|
245
|
+
await sleep((job.pollAfter ?? 2) * 1000)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Streams a finished pull to `path`, checking it against the hash the job stated. */
|
|
250
|
+
async download(job: Job, path: string): Promise<{ size: number }> {
|
|
251
|
+
if (!job.download) throw new Error('The job has nothing to download.')
|
|
252
|
+
const response = await this.call(job.download.url)
|
|
253
|
+
if (!response.ok || !response.body) throw await problemOf(response)
|
|
254
|
+
const hasher = createHash('sha256')
|
|
255
|
+
const sink = createWriteStream(path)
|
|
256
|
+
let size = 0
|
|
257
|
+
const reader = response.body.getReader()
|
|
258
|
+
for (;;) {
|
|
259
|
+
const { done, value } = await reader.read()
|
|
260
|
+
if (done) break
|
|
261
|
+
hasher.update(value)
|
|
262
|
+
size += value.length
|
|
263
|
+
if (!sink.write(value)) await new Promise((r) => sink.once('drain', r))
|
|
264
|
+
}
|
|
265
|
+
await new Promise<void>((resolve, reject) =>
|
|
266
|
+
sink.end((error?: Error | null) => (error ? reject(error) : resolve())),
|
|
267
|
+
)
|
|
268
|
+
if (job.download.sha256 && hasher.digest('hex') !== job.download.sha256)
|
|
269
|
+
throw new Error('The download does not match the hash the remote stated; try again.')
|
|
270
|
+
return { size }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A job still moving when the client stopped waiting. */
|
|
275
|
+
export class TimedOut extends Error {
|
|
276
|
+
constructor(readonly job: Job) {
|
|
277
|
+
super(`Still ${job.state} when the wait ran out.`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function problemOf(response: Response): Promise<RemoteError> {
|
|
282
|
+
const body = (await response.json().catch(() => ({}))) as Partial<Problem>
|
|
283
|
+
return new RemoteError({
|
|
284
|
+
status: response.status,
|
|
285
|
+
code: body.code ?? `http-${response.status}`,
|
|
286
|
+
title: body.title ?? `The remote answered ${response.status}`,
|
|
287
|
+
...(body.detail ? { detail: body.detail } : {}),
|
|
288
|
+
...(body.items ? { items: body.items } : {}),
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** SHA-256 of a file, streamed. */
|
|
293
|
+
export async function sha256File(path: string): Promise<string> {
|
|
294
|
+
const hasher = createHash('sha256')
|
|
295
|
+
for await (const chunk of Bun.file(path).stream()) hasher.update(chunk)
|
|
296
|
+
return hasher.digest('hex')
|
|
297
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `remotes.toml` at a site's root (story 108): the remotes a team pulls from
|
|
3
|
+
* and pushes to, by name — URLs only, never credentials, so it is committed.
|
|
4
|
+
*
|
|
5
|
+
* [remotes.production]
|
|
6
|
+
* url = "https://example.com"
|
|
7
|
+
* environment = "production"
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { writeFileAtomic } from '@crombolo/server'
|
|
12
|
+
|
|
13
|
+
export const REMOTES_FILE = 'remotes.toml'
|
|
14
|
+
|
|
15
|
+
export interface RemoteEntry {
|
|
16
|
+
name: string
|
|
17
|
+
url: string
|
|
18
|
+
/** The environment a pull or push goes to when `--env` is not given. */
|
|
19
|
+
environment?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NAME = /^[a-z][a-z0-9-]{0,39}$/
|
|
23
|
+
|
|
24
|
+
export function readRemotes(siteDir: string): RemoteEntry[] {
|
|
25
|
+
const file = join(siteDir, REMOTES_FILE)
|
|
26
|
+
if (!existsSync(file)) return []
|
|
27
|
+
const parsed = Bun.TOML.parse(readFileSync(file, 'utf8')) as {
|
|
28
|
+
remotes?: Record<string, { url?: unknown; environment?: unknown }>
|
|
29
|
+
}
|
|
30
|
+
return Object.entries(parsed.remotes ?? {}).flatMap(([name, entry]) =>
|
|
31
|
+
typeof entry?.url === 'string'
|
|
32
|
+
? [
|
|
33
|
+
{
|
|
34
|
+
name,
|
|
35
|
+
url: entry.url,
|
|
36
|
+
...(typeof entry.environment === 'string' ? { environment: entry.environment } : {}),
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
: [],
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const quote = (value: string) => JSON.stringify(value)
|
|
44
|
+
|
|
45
|
+
export function writeRemotes(siteDir: string, remotes: readonly RemoteEntry[]): void {
|
|
46
|
+
const lines = [
|
|
47
|
+
'# The remotes `crombolo pull` and `push` reach, by name. URLs only: sign-ins',
|
|
48
|
+
'# are kept in your keychain, never here.',
|
|
49
|
+
]
|
|
50
|
+
for (const remote of remotes) {
|
|
51
|
+
lines.push('', `[remotes.${remote.name}]`, `url = ${quote(remote.url)}`)
|
|
52
|
+
if (remote.environment) lines.push(`environment = ${quote(remote.environment)}`)
|
|
53
|
+
}
|
|
54
|
+
writeFileAtomic(join(siteDir, REMOTES_FILE), `${lines.join('\n')}\n`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Adds or replaces one, refusing a name that would not survive as a TOML key. */
|
|
58
|
+
export function addRemote(siteDir: string, remote: RemoteEntry): void {
|
|
59
|
+
if (!NAME.test(remote.name))
|
|
60
|
+
throw new Error(`"${remote.name}" is not a remote name: lower case letters, digits and dashes.`)
|
|
61
|
+
const others = readRemotes(siteDir).filter((r) => r.name !== remote.name)
|
|
62
|
+
writeRemotes(
|
|
63
|
+
siteDir,
|
|
64
|
+
[...others, remote].sort((a, b) => a.name.localeCompare(b.name)),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function removeRemote(siteDir: string, name: string): boolean {
|
|
69
|
+
const remotes = readRemotes(siteDir)
|
|
70
|
+
const kept = remotes.filter((r) => r.name !== name)
|
|
71
|
+
if (kept.length === remotes.length) return false
|
|
72
|
+
writeRemotes(siteDir, kept)
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The remote a command names, or the only one there is. */
|
|
77
|
+
export function pickRemote(siteDir: string, name: string | undefined): RemoteEntry | undefined {
|
|
78
|
+
const remotes = readRemotes(siteDir)
|
|
79
|
+
if (name) return remotes.find((r) => r.name === name)
|
|
80
|
+
return remotes.length === 1 ? remotes[0] : undefined
|
|
81
|
+
}
|