@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.
- package/bin/crombolo.ts +689 -23
- package/package.json +7 -7
- package/src/operations/commands.ts +108 -4
- package/src/remote/client.ts +288 -0
- package/src/remote/remotes-file.ts +80 -0
- package/src/remote/transfer.ts +337 -0
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/content/bundle.json +11 -4
- package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json +42 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +7 -0
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- package/templates/demo/harbourstone/files/components/pages/searchPage.tsx +76 -0
- package/templates/demo/harbourstone/files/components/shared/site-header.tsx +13 -1
- package/templates/demo/harbourstone/files/css/site.css +113 -0
- package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +2 -0
- package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +2 -0
- package/templates/demo/harbourstone/files/schema/content-types/expression.toml +8 -0
- package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +5 -1
- package/templates/demo/harbourstone/files/schema/content-types/search-page.toml +33 -0
- package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +2 -0
- package/templates/starter/bundle/content/bundle.json +12 -5
- package/templates/starter/bundle/content/nodes/c720ef2a-b5c8-48ec-a504-065e8188d4aa.json +28 -0
- package/templates/starter/bundle/package.json +1 -1
- package/templates/starter/files/components/pages/searchPage.tsx +57 -0
- package/templates/starter/files/components/shared/site-header.tsx +11 -2
- package/templates/starter/files/css/site.css +66 -0
- package/templates/starter/files/schema/content-types/content-page.toml +4 -1
- 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.
|
|
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.
|
|
38
|
-
"@crombolo/core": "0.
|
|
39
|
-
"@crombolo/data": "0.
|
|
40
|
-
"@crombolo/schema": "0.
|
|
41
|
-
"@crombolo/server": "0.
|
|
42
|
-
"@crombolo/transfer": "0.
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|