@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.
Files changed (85) hide show
  1. package/bin/crombolo.ts +4 -2767
  2. package/oclif.manifest.json +6710 -0
  3. package/package.json +58 -7
  4. package/src/base.ts +129 -0
  5. package/src/classify.ts +119 -0
  6. package/src/clispec.ts +163 -0
  7. package/src/commands/admin/reset-password.ts +53 -0
  8. package/src/commands/assets/index.ts +6 -0
  9. package/src/commands/assets/list.ts +27 -0
  10. package/src/commands/assets/new.ts +42 -0
  11. package/src/commands/bundle/purge.ts +71 -0
  12. package/src/commands/components/check.ts +58 -0
  13. package/src/commands/components/index.ts +6 -0
  14. package/src/commands/components/list.ts +40 -0
  15. package/src/commands/components/new.ts +49 -0
  16. package/src/commands/content/check.ts +102 -0
  17. package/src/commands/content/export.ts +116 -0
  18. package/src/commands/content/import.ts +109 -0
  19. package/src/commands/content/publish.ts +21 -0
  20. package/src/commands/content/revert.ts +137 -0
  21. package/src/commands/content/runs.ts +62 -0
  22. package/src/commands/content/unpublish.ts +21 -0
  23. package/src/commands/dictionary/export.ts +49 -0
  24. package/src/commands/dictionary/import.ts +46 -0
  25. package/src/commands/domains/apply.ts +23 -0
  26. package/src/commands/domains/clear.ts +27 -0
  27. package/src/commands/domains/index.ts +6 -0
  28. package/src/commands/domains/list.ts +86 -0
  29. package/src/commands/domains/set.ts +55 -0
  30. package/src/commands/domains/undo.ts +29 -0
  31. package/src/commands/export.ts +100 -0
  32. package/src/commands/generate.ts +33 -0
  33. package/src/commands/import/index.ts +175 -0
  34. package/src/commands/import/umbraco/apply.ts +128 -0
  35. package/src/commands/import/umbraco/report.ts +56 -0
  36. package/src/commands/init.ts +76 -0
  37. package/src/commands/login.ts +38 -0
  38. package/src/commands/logout.ts +36 -0
  39. package/src/commands/maintenance/pause.ts +29 -0
  40. package/src/commands/maintenance/resume.ts +20 -0
  41. package/src/commands/maintenance/status.ts +34 -0
  42. package/src/commands/media/fetch.ts +62 -0
  43. package/src/commands/pull.ts +210 -0
  44. package/src/commands/push.ts +108 -0
  45. package/src/commands/remote/add.ts +78 -0
  46. package/src/commands/remote/job.ts +53 -0
  47. package/src/commands/remote/list.ts +41 -0
  48. package/src/commands/remote/remove.ts +26 -0
  49. package/src/commands/schema.ts +32 -0
  50. package/src/commands/start.ts +64 -0
  51. package/src/commands/status.ts +103 -0
  52. package/src/commands/types/add-property.ts +116 -0
  53. package/src/commands/types/check.ts +88 -0
  54. package/src/commands/types/export.ts +32 -0
  55. package/src/commands/types/new.ts +116 -0
  56. package/src/commands/types/purge.ts +65 -0
  57. package/src/commands/types/rewrite.ts +30 -0
  58. package/src/commands/types/sync.ts +66 -0
  59. package/src/commands/upgrade/check.ts +69 -0
  60. package/src/commands/upgrade/index.ts +120 -0
  61. package/src/commands/upgrade/ledger.ts +55 -0
  62. package/src/commands/whoami.ts +41 -0
  63. package/src/errors.ts +90 -0
  64. package/src/main.ts +23 -0
  65. package/src/manifest.ts +20 -0
  66. package/src/reference.ts +118 -0
  67. package/src/remote/client.ts +297 -0
  68. package/src/remote/remotes-file.ts +81 -0
  69. package/src/remote/transfer.ts +353 -0
  70. package/src/support/components.ts +7 -0
  71. package/src/support/describe.ts +179 -0
  72. package/src/support/domains.ts +89 -0
  73. package/src/support/flags.ts +157 -0
  74. package/src/support/prompt.ts +126 -0
  75. package/src/support/publish.ts +142 -0
  76. package/src/support/remote.ts +58 -0
  77. package/src/support/serve.ts +460 -0
  78. package/src/support/site.ts +26 -0
  79. package/src/support/types.ts +112 -0
  80. package/src/support/umbraco.ts +86 -0
  81. package/templates/basic/bundle/content/bundle.json +1 -1
  82. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  83. package/templates/basic/bundle/package.json +1 -1
  84. package/templates/demo/harbourstone/bundle/package.json +1 -1
  85. 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
+ }