@crombolo/cli 0.14.4 → 0.16.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 (104) hide show
  1. package/bin/crombolo.ts +4 -3166
  2. package/oclif.manifest.json +8077 -0
  3. package/package.json +79 -8
  4. package/src/base.ts +134 -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/api-client/rotate.ts +57 -0
  9. package/src/commands/assets/index.ts +6 -0
  10. package/src/commands/assets/list.ts +27 -0
  11. package/src/commands/assets/new.ts +42 -0
  12. package/src/commands/bundle/purge.ts +71 -0
  13. package/src/commands/cache/rebuild.ts +56 -0
  14. package/src/commands/components/check.ts +58 -0
  15. package/src/commands/components/index.ts +6 -0
  16. package/src/commands/components/list.ts +40 -0
  17. package/src/commands/components/new.ts +49 -0
  18. package/src/commands/content/check.ts +102 -0
  19. package/src/commands/content/export.ts +116 -0
  20. package/src/commands/content/import.ts +109 -0
  21. package/src/commands/content/publish.ts +21 -0
  22. package/src/commands/content/revert.ts +137 -0
  23. package/src/commands/content/runs.ts +62 -0
  24. package/src/commands/content/unpublish.ts +21 -0
  25. package/src/commands/dictionary/export.ts +49 -0
  26. package/src/commands/dictionary/import.ts +46 -0
  27. package/src/commands/domains/apply.ts +23 -0
  28. package/src/commands/domains/clear.ts +27 -0
  29. package/src/commands/domains/index.ts +6 -0
  30. package/src/commands/domains/list.ts +86 -0
  31. package/src/commands/domains/set.ts +55 -0
  32. package/src/commands/domains/undo.ts +29 -0
  33. package/src/commands/export.ts +100 -0
  34. package/src/commands/generate.ts +33 -0
  35. package/src/commands/health.ts +90 -0
  36. package/src/commands/import/index.ts +175 -0
  37. package/src/commands/import/umbraco/apply.ts +128 -0
  38. package/src/commands/import/umbraco/report.ts +56 -0
  39. package/src/commands/index/list.ts +45 -0
  40. package/src/commands/index/rebuild.ts +66 -0
  41. package/src/commands/init.ts +76 -0
  42. package/src/commands/login.ts +38 -0
  43. package/src/commands/logout.ts +36 -0
  44. package/src/commands/maintenance/pause.ts +29 -0
  45. package/src/commands/maintenance/resume.ts +20 -0
  46. package/src/commands/maintenance/status.ts +34 -0
  47. package/src/commands/media/export.ts +91 -0
  48. package/src/commands/media/fetch.ts +62 -0
  49. package/src/commands/media/import.ts +175 -0
  50. package/src/commands/pull.ts +210 -0
  51. package/src/commands/push.ts +108 -0
  52. package/src/commands/recycle-bin/empty.ts +157 -0
  53. package/src/commands/remote/add.ts +78 -0
  54. package/src/commands/remote/job.ts +53 -0
  55. package/src/commands/remote/list.ts +41 -0
  56. package/src/commands/remote/remove.ts +26 -0
  57. package/src/commands/schema.ts +32 -0
  58. package/src/commands/start.ts +64 -0
  59. package/src/commands/status.ts +103 -0
  60. package/src/commands/types/add-property.ts +116 -0
  61. package/src/commands/types/check.ts +88 -0
  62. package/src/commands/types/export.ts +32 -0
  63. package/src/commands/types/new.ts +116 -0
  64. package/src/commands/types/purge.ts +65 -0
  65. package/src/commands/types/rewrite.ts +30 -0
  66. package/src/commands/types/sync.ts +66 -0
  67. package/src/commands/upgrade/check.ts +69 -0
  68. package/src/commands/upgrade/index.ts +120 -0
  69. package/src/commands/upgrade/ledger.ts +55 -0
  70. package/src/commands/users/disable.ts +54 -0
  71. package/src/commands/users/enable.ts +54 -0
  72. package/src/commands/users/invite.ts +82 -0
  73. package/src/commands/users/list.ts +62 -0
  74. package/src/commands/users/reset-2fa.ts +48 -0
  75. package/src/commands/users/reset-password.ts +58 -0
  76. package/src/commands/users/unlock.ts +54 -0
  77. package/src/commands/whoami.ts +41 -0
  78. package/src/errors.ts +90 -0
  79. package/src/load.ts +59 -0
  80. package/src/main.ts +41 -0
  81. package/src/manifest.ts +30 -0
  82. package/src/plugin.ts +38 -0
  83. package/src/reference.ts +118 -0
  84. package/src/remote/client.ts +25 -11
  85. package/src/remote/remotes-file.ts +3 -2
  86. package/src/remote/transfer.ts +27 -11
  87. package/src/support/components.ts +7 -0
  88. package/src/support/describe.ts +179 -0
  89. package/src/support/domains.ts +89 -0
  90. package/src/support/flags.ts +157 -0
  91. package/src/support/media-tree.ts +72 -0
  92. package/src/support/people.ts +134 -0
  93. package/src/support/prompt.ts +126 -0
  94. package/src/support/publish.ts +142 -0
  95. package/src/support/remote.ts +57 -0
  96. package/src/support/serve.ts +460 -0
  97. package/src/support/site.ts +26 -0
  98. package/src/support/types.ts +112 -0
  99. package/src/support/umbraco.ts +86 -0
  100. package/templates/basic/bundle/content/bundle.json +1 -1
  101. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  102. package/templates/basic/bundle/package.json +1 -1
  103. package/templates/demo/harbourstone/bundle/package.json +1 -1
  104. package/templates/starter/bundle/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ import { Args } from '@oclif/core'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import {
4
+ checkStatus,
5
+ describeUser,
6
+ OBSERVER,
7
+ OPERATOR,
8
+ openPeople,
9
+ userNamed,
10
+ } from '../../support/people.ts'
11
+ import { siteConfig } from '../../support/site.ts'
12
+
13
+ export default class UsersUnlock extends BaseCommand {
14
+ static override summary = "clear a user's lockout and failed sign-ins"
15
+ static override args = {
16
+ user: Args.string({ required: true, description: 'their e-mail address or username' }),
17
+ }
18
+ static override audit = { category: 'user', action: 'Unlock', entityType: 'User' } as const
19
+ static override spec = {
20
+ effects: 'idempotent',
21
+ cardinality: 'single',
22
+ outputFields: [
23
+ { name: 'id', type: 'string' },
24
+ { name: 'email', type: 'string' },
25
+ { name: 'state', type: 'string' },
26
+ { name: 'changed', type: 'boolean' },
27
+ ],
28
+ errors: ['not_found', 'refused'],
29
+ } as const
30
+
31
+ async run() {
32
+ const { args } = await this.parse(UsersUnlock)
33
+ const people = await openPeople(await siteConfig())
34
+ try {
35
+ const user = await userNamed(people, args.user)
36
+ this.auditKey = user.key
37
+ const snapshot = async () => {
38
+ const found = await people.users.get(OBSERVER, user.key)
39
+ return found.status === 'Success'
40
+ ? { state: found.value.state, failed: found.value.failedLoginAttempts }
41
+ : { state: 'unknown', failed: 0 }
42
+ }
43
+ const before = await snapshot()
44
+ checkStatus(await people.users.unlock(OPERATOR, [user.key]))
45
+ const after = await snapshot()
46
+ const state = after.state
47
+ const changed = before.state !== after.state || before.failed !== after.failed
48
+ this.say(` unlocked ${describeUser(user)}${changed ? '' : ' (no change)'}`)
49
+ return { id: user.key, email: user.email, state, changed }
50
+ } finally {
51
+ await people.db.close()
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,41 @@
1
+ import { Args } from '@oclif/core'
2
+ import { BaseCommand } from '../base.ts'
3
+ import { problemOf } from '../remote/client.ts'
4
+ import { connectRemote } from '../support/remote.ts'
5
+
6
+ export default class Whoami extends BaseCommand {
7
+ static override summary = 'who you are signed in as at a remote, and what you may do there'
8
+ static override args = {
9
+ remote: Args.string({ description: 'the remote; the only one, when there is one' }),
10
+ }
11
+ static override spec = {
12
+ effects: 'read_only',
13
+ cardinality: 'single',
14
+ outputFields: [
15
+ { name: 'name', type: 'string' },
16
+ { name: 'email', type: 'string' },
17
+ { name: 'remote', type: 'string' },
18
+ { name: 'environments', type: 'array', items: { type: 'object' } },
19
+ ],
20
+ errors: ['not_found', 'auth', 'unavailable'],
21
+ } as const
22
+
23
+ async run() {
24
+ const { args } = await this.parse(Whoami)
25
+ const { entry, client, discovery } = await connectRemote(args.remote, undefined)
26
+ const response = await client.call(
27
+ `${new URL(discovery.api).origin}/umbraco/management/api/v1/user/current`,
28
+ )
29
+ if (!response.ok) throw await problemOf(response)
30
+ const me = (await response.json()) as { name: string; email: string }
31
+ this.say(` ${me.name} <${me.email}> on ${entry.name}`)
32
+ const environments = await client.environments()
33
+ for (const env of environments) {
34
+ const may = [env.may.pull && 'pull', env.may.pullFull && 'full pull', env.may.push && 'push']
35
+ .filter(Boolean)
36
+ .join(', ')
37
+ this.say(` ${env.id.padEnd(12)} ${may || 'nothing'}${env.protected ? ' (protected)' : ''}`)
38
+ }
39
+ return { name: me.name, email: me.email, remote: entry.name, environments }
40
+ }
41
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * How a command fails, and what it exits with (story 150). Every failure is a
3
+ * kind with one exit code, so a script can branch on `$?` and a machine can read
4
+ * the envelope; an outcome is a finding the command was asked to look for, which
5
+ * exits non-zero with no envelope.
6
+ */
7
+
8
+ /** Each kind's exit code. The table in `docs/operations.md` § Exit codes is this one. */
9
+ export const ERROR_KINDS = {
10
+ internal: { exitCode: 1, description: 'Something unexpected went wrong.' },
11
+ usage: {
12
+ exitCode: 2,
13
+ description: 'The arguments are wrong: an unknown command or flag, or one missing.',
14
+ },
15
+ timeout: {
16
+ exitCode: 3,
17
+ description: 'Gave up waiting. A remote job carries on at the remote.',
18
+ retryable: true,
19
+ },
20
+ auth: {
21
+ exitCode: 4,
22
+ description: 'Not signed in, the sign-in has lapsed, or a passphrase is missing or wrong.',
23
+ },
24
+ not_found: {
25
+ exitCode: 5,
26
+ description: 'No such remote, environment, page, run, bundle, file or account.',
27
+ },
28
+ conflict: {
29
+ exitCode: 6,
30
+ description: 'Someone else is changing it, or it exists with a different value.',
31
+ retryable: true,
32
+ },
33
+ confirmation_required: {
34
+ exitCode: 7,
35
+ description: 'It would have asked first, and there is no terminal to ask on.',
36
+ },
37
+ refused: {
38
+ exitCode: 8,
39
+ description:
40
+ 'The site or the remote said no: findings outstanding, a permission, a protected environment.',
41
+ },
42
+ unavailable: {
43
+ exitCode: 9,
44
+ description: 'The database or the remote could not be reached.',
45
+ retryable: true,
46
+ },
47
+ io: { exitCode: 10, description: 'A file could not be read or written.' },
48
+ interrupted: { exitCode: 130, description: 'Stopped by a signal.' },
49
+ } as const satisfies Record<string, { exitCode: number; description: string; retryable?: boolean }>
50
+
51
+ export type ErrorKind = keyof typeof ERROR_KINDS
52
+
53
+ /** Findings a command was asked to look for. Their details are the command's normal output. */
54
+ export const OUTCOMES = {
55
+ problems_found: {
56
+ code: 20,
57
+ description: 'The check found problems: `status`, `types check`, `components check`.',
58
+ },
59
+ blocked: {
60
+ code: 21,
61
+ description:
62
+ 'Something must be resolved by a person first: `content check`, `upgrade check`, `import umbraco report`.',
63
+ },
64
+ } as const
65
+
66
+ export type OutcomeName = keyof typeof OUTCOMES
67
+
68
+ /** A failure with a kind, a sentence, and what to do about it. */
69
+ export class CliError extends Error {
70
+ readonly hint?: string
71
+ readonly details?: unknown
72
+ constructor(
73
+ readonly kind: ErrorKind,
74
+ message: string,
75
+ options: { hint?: string; details?: unknown } = {},
76
+ ) {
77
+ super(message)
78
+ this.hint = options.hint
79
+ this.details = options.details
80
+ }
81
+ }
82
+
83
+ /** The command finished, and found what it reports: exit with the outcome's code, no envelope. */
84
+ export class Outcome extends Error {
85
+ constructor(readonly outcome: OutcomeName) {
86
+ super(outcome)
87
+ }
88
+ }
89
+
90
+ export const exitCodeOf = (kind: ErrorKind): number => ERROR_KINDS[kind].exitCode
package/src/load.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The CLI's commands, with the optional plugins found beside it (story 158).
3
+ *
4
+ * oclif loads a plugin only when the CLI's own `package.json` depends on it,
5
+ * and warns when one is missing; an optional plugin is neither. So each name in
6
+ * `oclif.optionalPlugins` is looked for here — in the site's `node_modules`,
7
+ * then beside the CLI, as a global install puts it — loaded when it is found,
8
+ * and passed over in silence when it is not.
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { Config, Plugin } from '@oclif/core'
13
+ import { CLI_DIR } from './manifest.ts'
14
+
15
+ /** The directory a package is installed in, looked for from each of `from` upward. */
16
+ export function findInstalled(name: string, from: readonly string[]): string | undefined {
17
+ for (const start of from) {
18
+ let dir = start
19
+ for (;;) {
20
+ const candidate = join(dir, 'node_modules', name)
21
+ if (existsSync(join(candidate, 'package.json'))) return candidate
22
+ const parent = dirname(dir)
23
+ if (parent === dir) break
24
+ dir = parent
25
+ }
26
+ }
27
+ return undefined
28
+ }
29
+
30
+ export function optionalPlugins(): string[] {
31
+ const pjson = JSON.parse(readFileSync(join(CLI_DIR, 'package.json'), 'utf8')) as {
32
+ oclif?: { optionalPlugins?: string[] }
33
+ }
34
+ return pjson.oclif?.optionalPlugins ?? []
35
+ }
36
+
37
+ export interface LoadedCli {
38
+ config: Config
39
+ /** The optional plugins that are not installed. */
40
+ missing: string[]
41
+ }
42
+
43
+ export async function loadCli(cwd: string = process.cwd()): Promise<LoadedCli> {
44
+ const root = new Plugin({ root: CLI_DIR, isRoot: true, type: 'core' })
45
+ await root.load()
46
+ const plugins = new Map<string, Plugin>([[root.name, root]])
47
+ const missing: string[] = []
48
+ for (const name of optionalPlugins()) {
49
+ const dir = findInstalled(name, [cwd, CLI_DIR])
50
+ if (!dir) {
51
+ missing.push(name)
52
+ continue
53
+ }
54
+ const plugin = new Plugin({ root: dir, name, type: 'core' })
55
+ await plugin.load()
56
+ plugins.set(plugin.name, plugin)
57
+ }
58
+ return { config: await Config.load({ root: CLI_DIR, plugins }), missing }
59
+ }
package/src/main.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Runs the CLI. A failure inside a command is reported by the command
3
+ * (`BaseCommand.catch`); one before any command runs — an unknown command —
4
+ * is reported here the same way, so every failure has a kind and an exit code.
5
+ */
6
+ import { Errors, flush, run } from '@oclif/core'
7
+ import { wantsJson } from './base.ts'
8
+ import { CliError, exitCodeOf } from './errors.ts'
9
+ import { loadCli } from './load.ts'
10
+
11
+ /** The topic each optional plugin brings, and how to add it. */
12
+ const PLUGIN_TOPICS: Record<string, { topic: string; what: string }> = {
13
+ '@crombolo/cloud': { topic: 'cloud', what: 'Crombolo Cloud' },
14
+ }
15
+
16
+ export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
17
+ try {
18
+ const { config, missing } = await loadCli()
19
+ const absent = missing
20
+ .map((name) => ({ name, ...PLUGIN_TOPICS[name] }))
21
+ .find((plugin) => plugin.topic && argv[0] === plugin.topic)
22
+ if (absent)
23
+ throw new CliError(
24
+ 'usage',
25
+ `\`crombolo ${absent.topic}\` comes with the ${absent.what} plugin, which is not installed.`,
26
+ {
27
+ hint: `Add it to the site: bun add -d ${absent.name}`,
28
+ },
29
+ )
30
+ await run(argv, config)
31
+ await flush()
32
+ } catch (error) {
33
+ if (error instanceof Errors.ExitError) process.exit(error.oclif.exit ?? 0)
34
+ const { classify, reportFailure } = await import('./classify.ts')
35
+ const failure = classify(error)
36
+ if (failure.kind === 'usage' && !failure.hint)
37
+ Object.assign(failure, { hint: '`crombolo --help` lists the commands.' })
38
+ reportFailure(failure, wantsJson(argv))
39
+ process.exit(exitCodeOf(failure.kind))
40
+ }
41
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The CLI's `oclif.manifest.json`: every command's args, flags and clispec
3
+ * `spec`, cached so a start reads one file rather than importing every command.
4
+ */
5
+ import { join } from 'node:path'
6
+ import { Config, type Interfaces } from '@oclif/core'
7
+
8
+ export const CLI_DIR = join(import.meta.dir, '..')
9
+ export const MANIFEST_FILE = join(CLI_DIR, 'oclif.manifest.json')
10
+
11
+ export async function buildManifest(): Promise<Interfaces.Manifest> {
12
+ const config = await Config.load(CLI_DIR)
13
+ const commands = Object.fromEntries(
14
+ config.commands
15
+ .map((command) => [command.id, command] as const)
16
+ .sort(([a], [b]) => a.localeCompare(b)),
17
+ )
18
+ // Through JSON, as oclif reads it back: each command's `load` is dropped.
19
+ return JSON.parse(JSON.stringify({ commands, version: config.version })) as Interfaces.Manifest
20
+ }
21
+
22
+ /** The manifest's shape for any loaded config: the CLI's commands and its plugins'. */
23
+ export function manifestOf(config: Config): Interfaces.Manifest {
24
+ const commands = Object.fromEntries(
25
+ config.commands
26
+ .map((command) => [command.id, command] as const)
27
+ .sort(([a], [b]) => a.localeCompare(b)),
28
+ )
29
+ return JSON.parse(JSON.stringify({ commands, version: config.version })) as Interfaces.Manifest
30
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `@crombolo/cli/plugin`: what a plugin's commands are built from (story 158),
3
+ * so that they behave like the CLI's own — the global flags, the JSON envelope,
4
+ * the exit codes, an entry in `crombolo schema` — and sign in where `crombolo
5
+ * login` does. Covered by semver like the main entry. It loads none of the
6
+ * CLI's commands, and none of the site packages until a command fails.
7
+ *
8
+ * import { Args, BaseCommand, Flags } from '@crombolo/cli/plugin'
9
+ *
10
+ * export default class CloudStatus extends BaseCommand {
11
+ * static override summary = 'what the platform says about this site'
12
+ * static override spec = { effects: 'read_only', cardinality: 'single', outputFields: [...] } as const
13
+ * async run() { … return result }
14
+ * }
15
+ */
16
+ export { Args, Flags } from '@oclif/core'
17
+ export type { AuditSpec, CommandSpec, OutputField } from './base.ts'
18
+ export { BaseCommand } from './base.ts'
19
+ export {
20
+ CliError,
21
+ ERROR_KINDS,
22
+ type ErrorKind,
23
+ exitCodeOf,
24
+ OUTCOMES,
25
+ type OutcomeName,
26
+ } from './errors.ts'
27
+ export {
28
+ type Discovery,
29
+ discover,
30
+ type Job,
31
+ problemOf,
32
+ RemoteClient,
33
+ RemoteError,
34
+ signIn,
35
+ TimedOut,
36
+ } from './remote/client.ts'
37
+ export { keychain, openInBrowser } from './support/remote.ts'
38
+ export type { Io } from './support/site.ts'
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The command line reference as HTML, for the product site's developer pages:
3
+ * built from the CLI manifest's data, through the same clispec description
4
+ * `crombolo schema` prints, so the page cannot say anything the CLI does not.
5
+ */
6
+ import type { Interfaces } from '@oclif/core'
7
+ import { clispecDocument } from './clispec.ts'
8
+
9
+ const escapeHtml = (text: string): string =>
10
+ text
11
+ .replaceAll('&', '&amp;')
12
+ .replaceAll('<', '&lt;')
13
+ .replaceAll('>', '&gt;')
14
+ .replaceAll('"', '&quot;')
15
+
16
+ const code = (text: string) => `<code>${escapeHtml(text)}</code>`
17
+
18
+ interface Arg {
19
+ name: string
20
+ type: string
21
+ short?: string
22
+ required?: boolean
23
+ default?: unknown
24
+ enum?: unknown[]
25
+ description?: string
26
+ }
27
+
28
+ function usage(name: string, args: Arg[]): string {
29
+ const words = args.map((arg) => {
30
+ if (!arg.name.startsWith('--')) return arg.required ? `<${arg.name}>` : `[<${arg.name}>]`
31
+ const flag = arg.type === 'boolean' ? arg.name : `${arg.name} <${arg.name.slice(2)}>`
32
+ return arg.required ? flag : `[${flag}]`
33
+ })
34
+ return ['crombolo', name, ...words].join(' ')
35
+ }
36
+
37
+ /** A summary written for `--help` — "apply schema/ to the database" — as a sentence. */
38
+ const sentence = (text: string): string => {
39
+ const trimmed = text.trim()
40
+ if (!trimmed) return ''
41
+ const capital = `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`
42
+ return /[.!?]$/.test(capital) ? capital : `${capital}.`
43
+ }
44
+
45
+ function argRow(arg: Arg): string {
46
+ const name = [arg.short, arg.name].filter(Boolean).join(', ')
47
+ const notes = [
48
+ arg.description ? escapeHtml(sentence(arg.description)) : '',
49
+ arg.enum ? `One of ${arg.enum.map((v) => code(String(v))).join(', ')}.` : '',
50
+ arg.default !== undefined ? `Default ${code(String(arg.default))}.` : '',
51
+ arg.required ? 'Required.' : '',
52
+ ]
53
+ .filter(Boolean)
54
+ .join(' ')
55
+ return `<tr><td>${code(name)}</td><td>${notes}</td></tr>`
56
+ }
57
+
58
+ const EFFECTS: Record<string, string> = {
59
+ read_only: 'Changes nothing.',
60
+ idempotent: 'Safe to run again: a second run changes nothing more.',
61
+ non_idempotent: 'Each run acts again.',
62
+ }
63
+
64
+ /** The reference, grouped by the first word of each command. */
65
+ export function cliReferenceHtml(manifest: Interfaces.Manifest): string {
66
+ const document = clispecDocument({
67
+ bin: 'crombolo',
68
+ version: manifest.version,
69
+ commands: Object.values(manifest.commands),
70
+ })
71
+ const groups = new Map<string, typeof document.commands>()
72
+ for (const command of document.commands) {
73
+ const group = String(command.name).split(' ')[0] as string
74
+ groups.set(group, [...(groups.get(group) ?? []), command])
75
+ }
76
+ const parts: string[] = [
77
+ '<p>Every command, generated from the CLI itself. Each takes ' +
78
+ `${code('--help')} for this page's entry, and ${code('-o json')} for a result a script can read. ` +
79
+ `${code('crombolo schema')} prints the same description for a machine, as a ` +
80
+ '<a href="https://clispec.dev">clispec</a> document.</p>',
81
+ ]
82
+ for (const [group, commands] of groups) {
83
+ parts.push(`<h2 id="${escapeHtml(group)}">${escapeHtml(group)}</h2>`)
84
+ for (const command of commands) {
85
+ const name = String(command.name)
86
+ const args = ((command.args as Arg[] | undefined) ?? []).slice()
87
+ parts.push(
88
+ `<h3 id="${escapeHtml(name.replaceAll(' ', '-'))}">${code(`crombolo ${name}`)}</h3>`,
89
+ )
90
+ parts.push(
91
+ `<p>${escapeHtml(sentence(String(command.description)))} ${EFFECTS[String(command.effects)] ?? ''}</p>`,
92
+ )
93
+ parts.push(`<pre><code>${escapeHtml(usage(name, args))}</code></pre>`)
94
+ if (args.length > 0)
95
+ parts.push(
96
+ `<table><thead><tr><th>Argument</th><th>What it is</th></tr></thead><tbody>${args.map(argRow).join('')}</tbody></table>`,
97
+ )
98
+ }
99
+ }
100
+ parts.push('<h2 id="exit-codes">Exit codes</h2>')
101
+ parts.push(
102
+ `<p>A failure exits with its kind's code; with ${code('-o json')}, or whenever stderr is not a terminal, ` +
103
+ "the last line of stderr is a JSON envelope naming the kind. A check that finds what it looks for exits with an outcome's code, and its findings are its output.</p>",
104
+ )
105
+ const rows = [
106
+ ...document.errors.map((e) => [String(e.exit_code), e.kind, e.description] as const),
107
+ ...document.outcomes.map((o) => [String(o.code), o.name, o.description] as const),
108
+ ].sort((a, b) => Number(a[0]) - Number(b[0]))
109
+ parts.push(
110
+ `<table><thead><tr><th>Code</th><th>Kind</th><th>When</th></tr></thead><tbody>${rows
111
+ .map(
112
+ ([codeNumber, kind, description]) =>
113
+ `<tr><td>${codeNumber}</td><td>${code(kind)}</td><td>${escapeHtml(description).replaceAll(/`([^`]+)`/g, '<code>$1</code>')}</td></tr>`,
114
+ )
115
+ .join('')}</tbody></table>`,
116
+ )
117
+ return parts.join('\n')
118
+ }
@@ -6,7 +6,12 @@
6
6
  */
7
7
  import { createHash } from 'node:crypto'
8
8
  import { createWriteStream } from 'node:fs'
9
- import { freshAccessToken, type Keychain, readSignIn, saveSignIn } from '@crombolo/server'
9
+ import {
10
+ freshAccessToken,
11
+ type Keychain,
12
+ readSignIn,
13
+ saveSignIn,
14
+ } from '@crombolo/server/remote-credentials'
10
15
 
11
16
  export const PROTOCOL_VERSION = '1'
12
17
 
@@ -55,19 +60,28 @@ export async function discover(url: string, http: typeof fetch = fetch): Promise
55
60
  try {
56
61
  response = await http(`${origin}/.well-known/crombolo-remote`)
57
62
  } catch (error) {
58
- throw new Error(
59
- `${origin} could not be reached: ${error instanceof Error ? error.message : error}`,
60
- )
63
+ throw new RemoteError({
64
+ status: 0,
65
+ code: 'unreachable',
66
+ title: `${origin} could not be reached: ${error instanceof Error ? error.message : error}`,
67
+ })
61
68
  }
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.`)
69
+ const notARemote = () =>
70
+ new RemoteError({
71
+ status: 404,
72
+ code: 'not-a-remote',
73
+ title: `${origin} is not a crombolo remote.`,
74
+ })
75
+ if (response.status === 404) throw notARemote()
76
+ if (!response.ok) throw await problemOf(response)
65
77
  const body = (await response.json()) as Discovery
66
- if (body.protocol !== 'crombolo-remote') throw new Error(`${origin} is not a crombolo remote.`)
78
+ if (body.protocol !== 'crombolo-remote') throw notARemote()
67
79
  if (!body.versions?.includes(PROTOCOL_VERSION))
68
- throw new Error(
69
- `${origin} speaks protocol ${body.versions?.join(', ') || 'none'}; this crombolo speaks ${PROTOCOL_VERSION}.`,
70
- )
80
+ throw new RemoteError({
81
+ status: 0,
82
+ code: 'protocol-version',
83
+ title: `${origin} speaks protocol ${body.versions?.join(', ') || 'none'}; this crombolo speaks ${PROTOCOL_VERSION}.`,
84
+ })
71
85
  return body
72
86
  }
73
87
 
@@ -6,8 +6,9 @@
6
6
  * url = "https://example.com"
7
7
  * environment = "production"
8
8
  */
9
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { existsSync, readFileSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
+ import { writeFileAtomic } from '@crombolo/server/atomic-write'
11
12
 
12
13
  export const REMOTES_FILE = 'remotes.toml'
13
14
 
@@ -50,7 +51,7 @@ export function writeRemotes(siteDir: string, remotes: readonly RemoteEntry[]):
50
51
  lines.push('', `[remotes.${remote.name}]`, `url = ${quote(remote.url)}`)
51
52
  if (remote.environment) lines.push(`environment = ${quote(remote.environment)}`)
52
53
  }
53
- writeFileSync(join(siteDir, REMOTES_FILE), `${lines.join('\n')}\n`)
54
+ writeFileAtomic(join(siteDir, REMOTES_FILE), `${lines.join('\n')}\n`)
54
55
  }
55
56
 
56
57
  /** Adds or replaces one, refusing a name that would not survive as a TOML key. */
@@ -8,7 +8,7 @@
8
8
  * uploads it as a job — which the remote may hold for approval.
9
9
  */
10
10
  import { randomBytes } from 'node:crypto'
11
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
12
12
  import { tmpdir } from 'node:os'
13
13
  import { join } from 'node:path'
14
14
  import {
@@ -18,8 +18,10 @@ import {
18
18
  extractTarball,
19
19
  type MediaStore,
20
20
  mediaStoreFor,
21
+ writeFileAtomic,
21
22
  } from '@crombolo/server'
22
23
  import { type BundleBlob, loadBundle } from '@crombolo/transfer'
24
+ import { CliError, type ErrorKind } from '../errors.ts'
23
25
  import { type Job, problemOf, type RemoteClient, RemoteError, sha256File } from './client.ts'
24
26
 
25
27
  export type MediaMode = 'included' | 'omitted' | 'referenced'
@@ -27,8 +29,12 @@ export type MediaMode = 'included' | 'omitted' | 'referenced'
27
29
  /** A passphrase for one transfer: never shown, never stored. */
28
30
  export const transferPassphrase = () => randomBytes(24).toString('base64url')
29
31
 
30
- /** Refused before anything was sent: the CLI's own guard, exit code 2. */
31
- export class RefusedBeforeSending extends Error {}
32
+ /** Refused before anything was sent: the CLI's own guard. */
33
+ export class RefusedBeforeSending extends CliError {
34
+ constructor(kind: ErrorKind, message: string, hint?: string) {
35
+ super(kind, message, { hint })
36
+ }
37
+ }
32
38
 
33
39
  export interface PullResult {
34
40
  job: Job
@@ -54,15 +60,18 @@ export async function pullFile(
54
60
  const env = environments.find((e) => e.id === options.environment)
55
61
  if (!env)
56
62
  throw new RefusedBeforeSending(
63
+ 'not_found',
57
64
  `${client.discovery.name} has no environment "${options.environment}": it has ${environments.map((e) => e.id).join(', ')}.`,
58
65
  )
59
- if (!env.may.pull) throw new RefusedBeforeSending(`You may not pull from ${env.name}.`)
66
+ if (!env.may.pull) throw new RefusedBeforeSending('refused', `You may not pull from ${env.name}.`)
60
67
  if (options.full && !env.may.pullFull)
61
68
  throw new RefusedBeforeSending(
69
+ 'refused',
62
70
  `You may not pull a full copy of ${env.name}; pull without --full for an anonymised one.`,
63
71
  )
64
72
  if (options.media === 'referenced' && !client.discovery.capabilities.includes('media-fetch'))
65
73
  throw new RefusedBeforeSending(
74
+ 'refused',
66
75
  `${client.discovery.name} does not serve media by reference; pull without --media-info-only.`,
67
76
  )
68
77
  const passphrase = transferPassphrase()
@@ -163,8 +172,7 @@ export async function fetchReferencedMedia(
163
172
  }
164
173
  await Promise.all(Array.from({ length: 6 }, worker))
165
174
  }
166
- mkdirSync(join(options.siteDir, '.crombolo'), { recursive: true })
167
- writeFileSync(join(options.siteDir, FETCHED_FILE), `${JSON.stringify(fetched, null, 2)}\n`)
175
+ writeFileAtomic(join(options.siteDir, FETCHED_FILE), `${JSON.stringify(fetched, null, 2)}\n`)
168
176
  return result
169
177
  }
170
178
 
@@ -192,7 +200,8 @@ export interface PushCheck {
192
200
 
193
201
  export interface PushResult {
194
202
  check: PushCheck
195
- job: Job
203
+ /** Absent for a dry run, which stops after the check. */
204
+ job?: Job
196
205
  }
197
206
 
198
207
  /**
@@ -209,6 +218,8 @@ export async function pushSite(
209
218
  force?: boolean
210
219
  autoApprove?: boolean
211
220
  mediaInfoOnly?: boolean
221
+ /** Export and check, then stop: nothing is uploaded. */
222
+ dryRun?: boolean
212
223
  confirm?(check: PushCheck): Promise<boolean>
213
224
  onCheck?(check: PushCheck): void
214
225
  onProgress?(job: Job): void
@@ -222,15 +233,19 @@ export async function pushSite(
222
233
  const env = environments.find((e) => e.id === options.environment)
223
234
  if (!env)
224
235
  throw new RefusedBeforeSending(
236
+ 'not_found',
225
237
  `${client.discovery.name} has no environment "${options.environment}".`,
226
238
  )
227
- if (!env.may.push) throw new RefusedBeforeSending(`You may not push to ${env.name}.`)
228
- if (env.protected && !options.force)
239
+ if (!env.may.push) throw new RefusedBeforeSending('refused', `You may not push to ${env.name}.`)
240
+ if (env.protected && !options.force && !options.dryRun)
229
241
  throw new RefusedBeforeSending(
230
- `${env.name} is protected: pushing replaces what people rely on. Pass --force to go on.`,
242
+ 'confirmation_required',
243
+ `${env.name} is protected: pushing replaces what people rely on.`,
244
+ 'Pass --force to go on.',
231
245
  )
232
246
  if (options.mediaInfoOnly && !client.discovery.capabilities.includes('media-fetch'))
233
247
  throw new RefusedBeforeSending(
248
+ 'refused',
234
249
  `${client.discovery.name} does not take media by reference; push without --media-info-only.`,
235
250
  )
236
251
 
@@ -280,9 +295,10 @@ export async function pushSite(
280
295
  sending,
281
296
  }
282
297
  options.onCheck?.(check)
298
+ if (options.dryRun) return { check }
283
299
  if (env.protected && !options.autoApprove) {
284
300
  const yes = (await options.confirm?.(check)) ?? false
285
- if (!yes) throw new RefusedBeforeSending('Nothing was sent.')
301
+ if (!yes) throw new RefusedBeforeSending('refused', 'Nothing was sent.')
286
302
  }
287
303
 
288
304
  if (options.mediaInfoOnly && manifest.blobs.length > 0) {
@@ -0,0 +1,7 @@
1
+ /** The content types a site declares, which say which components are templates. */
2
+ import { existsSync } from 'node:fs'
3
+ import type { CromboloConfig } from '@crombolo/server'
4
+ import { loadTypes } from './types.ts'
5
+
6
+ export const declaredTypes = (config: CromboloConfig) =>
7
+ existsSync(config.schemaDir) ? loadTypes(config).set.contentTypes : []