@crombolo/cli 0.13.2 → 0.14.4

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 (31) hide show
  1. package/bin/crombolo.ts +689 -23
  2. package/package.json +7 -7
  3. package/src/operations/commands.ts +108 -4
  4. package/src/remote/client.ts +288 -0
  5. package/src/remote/remotes-file.ts +80 -0
  6. package/src/remote/transfer.ts +337 -0
  7. package/templates/basic/bundle/package.json +1 -1
  8. package/templates/demo/harbourstone/bundle/content/bundle.json +11 -4
  9. package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +7 -0
  10. package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +7 -0
  11. package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json +42 -0
  12. package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +7 -0
  13. package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +7 -0
  14. package/templates/demo/harbourstone/bundle/package.json +1 -1
  15. package/templates/demo/harbourstone/files/components/pages/searchPage.tsx +76 -0
  16. package/templates/demo/harbourstone/files/components/shared/site-header.tsx +13 -1
  17. package/templates/demo/harbourstone/files/css/site.css +113 -0
  18. package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +2 -0
  19. package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +2 -0
  20. package/templates/demo/harbourstone/files/schema/content-types/expression.toml +8 -0
  21. package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +5 -1
  22. package/templates/demo/harbourstone/files/schema/content-types/search-page.toml +33 -0
  23. package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +2 -0
  24. package/templates/starter/bundle/content/bundle.json +12 -5
  25. package/templates/starter/bundle/content/nodes/c720ef2a-b5c8-48ec-a504-065e8188d4aa.json +28 -0
  26. package/templates/starter/bundle/package.json +1 -1
  27. package/templates/starter/files/components/pages/searchPage.tsx +57 -0
  28. package/templates/starter/files/components/shared/site-header.tsx +11 -2
  29. package/templates/starter/files/css/site.css +66 -0
  30. package/templates/starter/files/schema/content-types/content-page.toml +4 -1
  31. package/templates/starter/files/schema/content-types/search-page.toml +21 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/cli",
3
- "version": "0.13.2",
3
+ "version": "0.14.4",
4
4
  "description": "The crombolo command line: scaffolding a site, schema, views, domains, content transfer and upgrades.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -34,11 +34,11 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@crombolo/auth": "0.13.2",
38
- "@crombolo/core": "0.13.2",
39
- "@crombolo/data": "0.13.2",
40
- "@crombolo/schema": "0.13.2",
41
- "@crombolo/server": "0.13.2",
42
- "@crombolo/transfer": "0.13.2"
37
+ "@crombolo/auth": "0.14.4",
38
+ "@crombolo/core": "0.14.4",
39
+ "@crombolo/data": "0.14.4",
40
+ "@crombolo/schema": "0.14.4",
41
+ "@crombolo/server": "0.14.4",
42
+ "@crombolo/transfer": "0.14.4"
43
43
  }
44
44
  }
@@ -5,7 +5,10 @@
5
5
  * a platform's own tooling — gets the same results as data. One implementation,
6
6
  * so the two can never disagree about what an import or an upgrade does.
7
7
  */
8
+ import { existsSync } from 'node:fs'
9
+ import { rm } from 'node:fs/promises'
8
10
  import { userInfo } from 'node:os'
11
+ import { basename, join, resolve } from 'node:path'
9
12
  import { ROOT_ACCESS } from '@crombolo/core'
10
13
  import {
11
14
  type AuditCategory,
@@ -46,6 +49,8 @@ import {
46
49
  checkSchemaDirectory,
47
50
  createFileIntake,
48
51
  createMediaPort,
52
+ exportSite,
53
+ extractTarball,
49
54
  formTypesFor,
50
55
  installBundleContent,
51
56
  installDatabase,
@@ -53,11 +58,18 @@ import {
53
58
  mediaStoreFor,
54
59
  pendingBundleSteps,
55
60
  planBundleUpgrades,
61
+ type ReplaceMode,
62
+ type ReplaceSummary,
63
+ RUN_BACKUP_DIR,
56
64
  readBlobs,
57
65
  readBundle,
58
66
  recordAudit,
67
+ replaceSummary,
68
+ type SiteExportOptions,
69
+ type SiteExportResult,
59
70
  syncOptionsFor,
60
71
  type TransferInput,
72
+ useSchemaStore,
61
73
  VERSION,
62
74
  } from '@crombolo/server'
63
75
  import {
@@ -95,6 +107,7 @@ export {
95
107
  readBundle,
96
108
  replacementsNeedingPermission,
97
109
  restoreSectionFiles,
110
+ revertSections,
98
111
  type SectionFile,
99
112
  type SectionOptions,
100
113
  type SectionPlan,
@@ -109,16 +122,39 @@ export {
109
122
  type Resolution,
110
123
  type TransferCheck,
111
124
  } from '@crombolo/transfer'
112
- export type { BackupResult, WritesPaused }
125
+ export type { BackupResult, ReplaceMode, ReplaceSummary, WritesPaused }
126
+
127
+ /**
128
+ * A bundle file (`.tgz`, as `export --site` writes) unpacked under the site's
129
+ * transfer directory, where an import can read it; a directory or a package
130
+ * name is a bundle as it is.
131
+ */
132
+ export async function unpackBundle(target: string, siteDir: string): Promise<string> {
133
+ if (!/\.(tgz|tar\.gz)$/i.test(target)) return target
134
+ const file = resolve(siteDir, target)
135
+ if (!existsSync(file)) throw new CommandError(`${target} does not exist.`)
136
+ const dir = join(
137
+ siteDir,
138
+ RUN_BACKUP_DIR,
139
+ 'incoming',
140
+ basename(file).replace(/\.(tgz|tar\.gz)$/i, ''),
141
+ )
142
+ await rm(dir, { recursive: true, force: true })
143
+ await extractTarball(file, dir)
144
+ return dir
145
+ }
113
146
 
114
147
  /** Read-only: what installing a bundle here would do. Its findings go to the Changes dashboard. */
115
148
  export async function contentCheck(
116
149
  config: CromboloConfig,
117
150
  input: TransferInput,
118
151
  ): Promise<ContentCheckResult> {
152
+ // Against the store's schema, not whatever local directory there is (story 106).
153
+ await useSchemaStore(config)
154
+ const bundle = await unpackBundle(input.bundle, config.siteDir)
119
155
  const { db } = await bootstrapDatabase(config)
120
156
  try {
121
- return await checkBundleContent(db, config, input)
157
+ return await checkBundleContent(db, config, { ...input, bundle })
122
158
  } finally {
123
159
  await db.close()
124
160
  }
@@ -129,12 +165,80 @@ export async function contentImport(
129
165
  config: CromboloConfig,
130
166
  input: ContentImportInput,
131
167
  ): Promise<ContentImportResult> {
168
+ const bundle = await unpackBundle(input.bundle, config.siteDir)
132
169
  // Read before anything is opened or backed up, so a bundle that is not one is
133
170
  // refused before a backup is taken on its behalf.
134
- readBundle(input.bundle, config.siteDir)
171
+ readBundle(bundle, config.siteDir)
172
+ await useSchemaStore(config)
173
+ const { db } = await bootstrapDatabase(config)
174
+ try {
175
+ return await installBundleContent(db, config, { ...input, bundle })
176
+ } finally {
177
+ await db.close()
178
+ }
179
+ }
180
+
181
+ export interface SiteReplaceCheck {
182
+ check: ContentCheckResult
183
+ summary: ReplaceSummary
184
+ }
185
+
186
+ /**
187
+ * Read-only: what `crombolo import <file>` would do (story 105) — the content
188
+ * check a replace runs with, and what it would take out of this site.
189
+ */
190
+ export async function siteReplaceCheck(
191
+ config: CromboloConfig,
192
+ input: TransferInput & { replace: ReplaceMode },
193
+ ): Promise<SiteReplaceCheck> {
194
+ await useSchemaStore(config)
195
+ const bundle = await unpackBundle(input.bundle, config.siteDir)
196
+ const resolved = readBundle(bundle, config.siteDir, input.passphrase)
135
197
  const { db } = await bootstrapDatabase(config)
136
198
  try {
137
- return await installBundleContent(db, config, input)
199
+ const check = await checkBundleContent(db, config, {
200
+ ...input,
201
+ bundle,
202
+ resolveAll: 'take-bundle',
203
+ replaceFiles: true,
204
+ })
205
+ const summary = await replaceSummary(
206
+ db,
207
+ resolved.set,
208
+ new Set(resolved.carried.files.keys()),
209
+ [
210
+ { section: 'schema', dir: config.schemaDir },
211
+ { section: 'components', dir: config.componentsDir },
212
+ { section: 'styles', dir: config.stylesheetsDir },
213
+ { section: 'scripts', dir: config.scriptsDir },
214
+ ],
215
+ input.replace,
216
+ )
217
+ return { check, summary }
218
+ } finally {
219
+ await db.close()
220
+ }
221
+ }
222
+
223
+ export type SiteExportInput = SiteExportOptions
224
+
225
+ /**
226
+ * Writes the whole site to one file (story 104), streaming its media from the
227
+ * store. Unlike `contentExport` this writes: a site's media does not fit in a
228
+ * returned value.
229
+ */
230
+ export async function siteExport(
231
+ config: CromboloConfig,
232
+ input: SiteExportInput,
233
+ ): Promise<SiteExportResult> {
234
+ await useSchemaStore(config)
235
+ const { db } = await bootstrapDatabase(config)
236
+ try {
237
+ return await exportSite(db, config, input)
238
+ } catch (error) {
239
+ if (error instanceof Error && !(error instanceof CommandError))
240
+ throw new CommandError(error.message)
241
+ throw error
138
242
  } finally {
139
243
  await db.close()
140
244
  }
@@ -0,0 +1,288 @@
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 Error(
59
+ `${origin} could not be reached: ${error instanceof Error ? error.message : error}`,
60
+ )
61
+ }
62
+ if (response.status === 404) throw new Error(`${origin} is not a crombolo remote.`)
63
+ if (!response.ok)
64
+ throw new Error(`${origin} answered ${response.status} for its discovery document.`)
65
+ const body = (await response.json()) as Discovery
66
+ if (body.protocol !== 'crombolo-remote') throw new Error(`${origin} is not a crombolo remote.`)
67
+ if (!body.versions?.includes(PROTOCOL_VERSION))
68
+ throw new Error(
69
+ `${origin} speaks protocol ${body.versions?.join(', ') || 'none'}; this crombolo speaks ${PROTOCOL_VERSION}.`,
70
+ )
71
+ return body
72
+ }
73
+
74
+ /**
75
+ * Signs in by the device grant (RFC 8628): `onCode` shows the person the code
76
+ * and the page to approve it on, then this polls until they decide. The tokens
77
+ * go into the keychain.
78
+ */
79
+ export async function signIn(
80
+ discovery: Discovery,
81
+ keychain: Keychain,
82
+ options: {
83
+ clientName: string
84
+ onCode(code: string, url: string): void | Promise<void>
85
+ http?: typeof fetch
86
+ sleep?: (ms: number) => Promise<void>
87
+ },
88
+ ): Promise<void> {
89
+ const http = options.http ?? fetch
90
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms))
91
+ const form = (url: string, fields: Record<string, string>) =>
92
+ http(url, {
93
+ method: 'POST',
94
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
95
+ body: new URLSearchParams(fields).toString(),
96
+ })
97
+ const started = await form(discovery.auth.deviceAuthorization, {
98
+ client_id: discovery.auth.clientId,
99
+ client_name: options.clientName,
100
+ })
101
+ if (!started.ok) throw new Error(`The remote would not start a sign-in (${started.status}).`)
102
+ const codes = (await started.json()) as {
103
+ device_code: string
104
+ user_code: string
105
+ verification_uri: string
106
+ verification_uri_complete?: string
107
+ expires_in: number
108
+ interval: number
109
+ }
110
+ await options.onCode(codes.user_code, codes.verification_uri_complete ?? codes.verification_uri)
111
+ let interval = codes.interval * 1000
112
+ const deadline = Date.now() + codes.expires_in * 1000
113
+ for (;;) {
114
+ await sleep(interval)
115
+ const polled = await form(discovery.auth.token, {
116
+ grant_type: discovery.auth.grantType,
117
+ device_code: codes.device_code,
118
+ client_id: discovery.auth.clientId,
119
+ })
120
+ const body = (await polled.json().catch(() => ({}))) as {
121
+ error?: string
122
+ access_token?: string
123
+ refresh_token?: string
124
+ expires_in?: number
125
+ }
126
+ if (polled.ok && body.access_token) {
127
+ await saveSignIn(keychain, discovery.api, {
128
+ accessToken: body.access_token,
129
+ refreshToken: body.refresh_token ?? '',
130
+ expiresAt: Date.now() + (body.expires_in ?? 0) * 1000,
131
+ tokenUrl: discovery.auth.token,
132
+ clientId: discovery.auth.clientId,
133
+ })
134
+ return
135
+ }
136
+ if (body.error === 'slow_down') interval += 5000
137
+ else if (body.error === 'access_denied') throw new Error('The sign-in was declined.')
138
+ else if (body.error === 'expired_token' || Date.now() > deadline)
139
+ throw new Error('The code expired before it was approved. Sign in again.')
140
+ else if (body.error !== 'authorization_pending')
141
+ throw new Error(`The sign-in failed: ${body.error ?? polled.status}`)
142
+ }
143
+ }
144
+
145
+ /** The protocol's API for one remote, as the signed-in person. */
146
+ export class RemoteClient {
147
+ readonly discovery: Discovery
148
+ #keychain: Keychain
149
+ #http: typeof fetch
150
+
151
+ constructor(discovery: Discovery, keychain: Keychain, http: typeof fetch = fetch) {
152
+ this.discovery = discovery
153
+ this.#keychain = keychain
154
+ this.#http = http
155
+ }
156
+
157
+ async signedIn(): Promise<boolean> {
158
+ return (await readSignIn(this.#keychain, this.discovery.api)) !== undefined
159
+ }
160
+
161
+ /** One authorised request; a 401 refreshes the sign-in once and tries again. */
162
+ async call(url: string, init: RequestInit = {}): Promise<Response> {
163
+ const attempt = async (force: boolean) => {
164
+ const token = await freshAccessToken(this.#keychain, this.discovery.api, this.#http, force)
165
+ if (!token)
166
+ throw new RemoteError({
167
+ status: 401,
168
+ code: 'sign-in-required',
169
+ title: `Not signed in to ${new URL(this.discovery.api).origin}`,
170
+ detail: 'Run `crombolo login` first.',
171
+ })
172
+ const headers = new Headers(init.headers)
173
+ headers.set('authorization', `Bearer ${token}`)
174
+ return this.#http(url, { ...init, headers })
175
+ }
176
+ const first = await attempt(false)
177
+ if (first.status !== 401) return first
178
+ return attempt(true)
179
+ }
180
+
181
+ async api(path: string, init: RequestInit = {}): Promise<Response> {
182
+ return this.call(`${this.discovery.api}/${path}`, init)
183
+ }
184
+
185
+ async json<T>(path: string, init: RequestInit = {}): Promise<T> {
186
+ const response = await this.api(path, init)
187
+ if (!response.ok) throw await problemOf(response)
188
+ return (await response.json()) as T
189
+ }
190
+
191
+ post<T>(path: string, body: unknown): Promise<T> {
192
+ return this.json<T>(path, {
193
+ method: 'POST',
194
+ headers: { 'content-type': 'application/json' },
195
+ body: JSON.stringify(body),
196
+ })
197
+ }
198
+
199
+ environments() {
200
+ return this.json<
201
+ Array<{
202
+ id: string
203
+ name: string
204
+ protected: boolean
205
+ may: { pull: boolean; pullFull: boolean; push: boolean }
206
+ }>
207
+ >('environments')
208
+ }
209
+
210
+ /** Waits for a job to stop moving, reporting each change; `onApproval` hears it once. */
211
+ async settle(
212
+ id: string,
213
+ options: {
214
+ onProgress?(job: Job): void
215
+ onApproval?(job: Job): void | Promise<void>
216
+ timeoutMs?: number
217
+ sleep?: (ms: number) => Promise<void>
218
+ } = {},
219
+ ): Promise<Job> {
220
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms))
221
+ const deadline = Date.now() + (options.timeoutMs ?? 30 * 60_000)
222
+ let announced = false
223
+ let last = ''
224
+ for (;;) {
225
+ const job = await this.json<Job>(`jobs/${id}`)
226
+ const seen = `${job.state}|${job.stage ?? ''}|${job.progress ?? ''}`
227
+ if (seen !== last) options.onProgress?.(job)
228
+ last = seen
229
+ if (job.state === 'approval-required' && !announced) {
230
+ announced = true
231
+ await options.onApproval?.(job)
232
+ }
233
+ if (job.state !== 'queued' && job.state !== 'running' && job.state !== 'approval-required')
234
+ return job
235
+ if (Date.now() > deadline) throw new TimedOut(job)
236
+ await sleep((job.pollAfter ?? 2) * 1000)
237
+ }
238
+ }
239
+
240
+ /** Streams a finished pull to `path`, checking it against the hash the job stated. */
241
+ async download(job: Job, path: string): Promise<{ size: number }> {
242
+ if (!job.download) throw new Error('The job has nothing to download.')
243
+ const response = await this.call(job.download.url)
244
+ if (!response.ok || !response.body) throw await problemOf(response)
245
+ const hasher = createHash('sha256')
246
+ const sink = createWriteStream(path)
247
+ let size = 0
248
+ const reader = response.body.getReader()
249
+ for (;;) {
250
+ const { done, value } = await reader.read()
251
+ if (done) break
252
+ hasher.update(value)
253
+ size += value.length
254
+ if (!sink.write(value)) await new Promise((r) => sink.once('drain', r))
255
+ }
256
+ await new Promise<void>((resolve, reject) =>
257
+ sink.end((error?: Error | null) => (error ? reject(error) : resolve())),
258
+ )
259
+ if (job.download.sha256 && hasher.digest('hex') !== job.download.sha256)
260
+ throw new Error('The download does not match the hash the remote stated; try again.')
261
+ return { size }
262
+ }
263
+ }
264
+
265
+ /** A job still moving when the client stopped waiting. */
266
+ export class TimedOut extends Error {
267
+ constructor(readonly job: Job) {
268
+ super(`Still ${job.state} when the wait ran out.`)
269
+ }
270
+ }
271
+
272
+ export async function problemOf(response: Response): Promise<RemoteError> {
273
+ const body = (await response.json().catch(() => ({}))) as Partial<Problem>
274
+ return new RemoteError({
275
+ status: response.status,
276
+ code: body.code ?? `http-${response.status}`,
277
+ title: body.title ?? `The remote answered ${response.status}`,
278
+ ...(body.detail ? { detail: body.detail } : {}),
279
+ ...(body.items ? { items: body.items } : {}),
280
+ })
281
+ }
282
+
283
+ /** SHA-256 of a file, streamed. */
284
+ export async function sha256File(path: string): Promise<string> {
285
+ const hasher = createHash('sha256')
286
+ for await (const chunk of Bun.file(path).stream()) hasher.update(chunk)
287
+ return hasher.digest('hex')
288
+ }
@@ -0,0 +1,80 @@
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, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ export const REMOTES_FILE = 'remotes.toml'
13
+
14
+ export interface RemoteEntry {
15
+ name: string
16
+ url: string
17
+ /** The environment a pull or push goes to when `--env` is not given. */
18
+ environment?: string
19
+ }
20
+
21
+ const NAME = /^[a-z][a-z0-9-]{0,39}$/
22
+
23
+ export function readRemotes(siteDir: string): RemoteEntry[] {
24
+ const file = join(siteDir, REMOTES_FILE)
25
+ if (!existsSync(file)) return []
26
+ const parsed = Bun.TOML.parse(readFileSync(file, 'utf8')) as {
27
+ remotes?: Record<string, { url?: unknown; environment?: unknown }>
28
+ }
29
+ return Object.entries(parsed.remotes ?? {}).flatMap(([name, entry]) =>
30
+ typeof entry?.url === 'string'
31
+ ? [
32
+ {
33
+ name,
34
+ url: entry.url,
35
+ ...(typeof entry.environment === 'string' ? { environment: entry.environment } : {}),
36
+ },
37
+ ]
38
+ : [],
39
+ )
40
+ }
41
+
42
+ const quote = (value: string) => JSON.stringify(value)
43
+
44
+ export function writeRemotes(siteDir: string, remotes: readonly RemoteEntry[]): void {
45
+ const lines = [
46
+ '# The remotes `crombolo pull` and `push` reach, by name. URLs only: sign-ins',
47
+ '# are kept in your keychain, never here.',
48
+ ]
49
+ for (const remote of remotes) {
50
+ lines.push('', `[remotes.${remote.name}]`, `url = ${quote(remote.url)}`)
51
+ if (remote.environment) lines.push(`environment = ${quote(remote.environment)}`)
52
+ }
53
+ writeFileSync(join(siteDir, REMOTES_FILE), `${lines.join('\n')}\n`)
54
+ }
55
+
56
+ /** Adds or replaces one, refusing a name that would not survive as a TOML key. */
57
+ export function addRemote(siteDir: string, remote: RemoteEntry): void {
58
+ if (!NAME.test(remote.name))
59
+ throw new Error(`"${remote.name}" is not a remote name: lower case letters, digits and dashes.`)
60
+ const others = readRemotes(siteDir).filter((r) => r.name !== remote.name)
61
+ writeRemotes(
62
+ siteDir,
63
+ [...others, remote].sort((a, b) => a.name.localeCompare(b.name)),
64
+ )
65
+ }
66
+
67
+ export function removeRemote(siteDir: string, name: string): boolean {
68
+ const remotes = readRemotes(siteDir)
69
+ const kept = remotes.filter((r) => r.name !== name)
70
+ if (kept.length === remotes.length) return false
71
+ writeRemotes(siteDir, kept)
72
+ return true
73
+ }
74
+
75
+ /** The remote a command names, or the only one there is. */
76
+ export function pickRemote(siteDir: string, name: string | undefined): RemoteEntry | undefined {
77
+ const remotes = readRemotes(siteDir)
78
+ if (name) return remotes.find((r) => r.name === name)
79
+ return remotes.length === 1 ? remotes[0] : undefined
80
+ }