@crombolo/cli 0.14.4 → 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 -3166
  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 +19 -10
  68. package/src/remote/remotes-file.ts +3 -2
  69. package/src/remote/transfer.ts +27 -11
  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,69 @@
1
+ import { Flags } from '@oclif/core'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import * as ops from '../../operations/commands.ts'
4
+ import { findingsOf, printFindings } from '../../support/describe.ts'
5
+ import { backupTaken, many } from '../../support/flags.ts'
6
+ import { siteConfig } from '../../support/site.ts'
7
+ import { parseSet, upgradeCheck } from '../../support/types.ts'
8
+
9
+ export default class UpgradeCheck extends BaseCommand {
10
+ static override summary = "what the deployed schema/ would do to this database's data"
11
+ static override description = [
12
+ 'Exits 21 when a finding needs a person first.',
13
+ '--fix backs up, then applies the additive part and every conversion early, so the cut-over is short.',
14
+ ].join('\n\n')
15
+ static override flags = {
16
+ fix: Flags.boolean({
17
+ summary: 'back up, then apply the additive part and every conversion early',
18
+ }),
19
+ set: many('a value for a new mandatory property: <type>.<property>=<value>', '<t.p=v>'),
20
+ 'backup-taken': backupTaken,
21
+ }
22
+ static override audit = { category: 'system', action: 'UpgradeFix' } as const
23
+ static override spec = {
24
+ effects: 'idempotent',
25
+ cardinality: 'single',
26
+ outputFields: [
27
+ { name: 'classification', type: 'string' },
28
+ { name: 'findings', type: 'array', items: { type: 'object' } },
29
+ { name: 'blocking', type: 'boolean' },
30
+ { name: 'applied', type: 'array', items: { type: 'string' } },
31
+ { name: 'backup', type: 'string', nullable: true },
32
+ ],
33
+ outcomes: ['blocked'],
34
+ } as const
35
+
36
+ async run() {
37
+ const { flags } = await this.parse(UpgradeCheck)
38
+ const config = await siteConfig()
39
+ const set = parseSet(flags.set)
40
+ if (!flags.fix) {
41
+ this.changedNothing = true
42
+ const report = await upgradeCheck(this, config, set)
43
+ if (report.blocking) this.outcome = 'blocked'
44
+ return {
45
+ classification: report.classification,
46
+ findings: findingsOf(report.findings),
47
+ blocking: report.blocking,
48
+ applied: [],
49
+ backup: null,
50
+ }
51
+ }
52
+ const fix = await ops.upgradeFix(config, { set, backupTaken: flags['backup-taken'] })
53
+ if (fix.backup.path) this.progress(` backup ${fix.backup.path}`)
54
+ this.say(
55
+ ` sync ${fix.sync.action}${fix.sync.deferred.length > 0 ? ` (deferred to the cut-over: ${fix.sync.deferred.join(', ')})` : ''}`,
56
+ )
57
+ for (const name of fix.applied) this.say(` applied ${name}`)
58
+ if (fix.skipped > 0)
59
+ this.say(` skipped ${fix.skipped} value(s) a converter refused; see the findings`)
60
+ printFindings(this, fix)
61
+ return {
62
+ classification: fix.classification,
63
+ findings: findingsOf(fix.findings),
64
+ blocking: fix.findings.some((f) => f.kind === 'blocking'),
65
+ applied: fix.applied,
66
+ backup: fix.backup.path ?? null,
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,120 @@
1
+ import { Flags } from '@oclif/core'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import * as ops from '../../operations/commands.ts'
4
+ import { findingsOf } from '../../support/describe.ts'
5
+ import { backupTaken, many } from '../../support/flags.ts'
6
+ import { siteConfig } from '../../support/site.ts'
7
+ import { parseSet } from '../../support/types.ts'
8
+
9
+ export default class Upgrade extends BaseCommand {
10
+ static override summary = 'back up, apply pending framework migrations, cut over, and record them'
11
+ static override description = [
12
+ 'Re-checks first, and refuses while a finding is outstanding; `upgrade check --fix` resolves what it can early.',
13
+ '--plan prints the DDL each pending migration would run, and changes nothing.',
14
+ ].join('\n\n')
15
+ static override flags = {
16
+ plan: Flags.boolean({
17
+ summary: 'print the DDL pending framework migrations would run, and stop',
18
+ }),
19
+ force: Flags.string({
20
+ summary: 'go on over outstanding findings, saying why',
21
+ helpValue: '<reason>',
22
+ }),
23
+ set: many('a value for a new mandatory property: <type>.<property>=<value>', '<t.p=v>'),
24
+ 'backup-taken': backupTaken,
25
+ }
26
+ static override audit = { category: 'system', action: 'Upgrade' } as const
27
+ static override spec = {
28
+ effects: 'idempotent',
29
+ cardinality: 'single',
30
+ outputFields: [
31
+ { name: 'action', type: 'string' },
32
+ { name: 'changed', type: 'boolean' },
33
+ { name: 'backup', type: 'string', nullable: true },
34
+ { name: 'frameworkApplied', type: 'array', items: { type: 'string' } },
35
+ { name: 'bundlesApplied', type: 'array', items: { type: 'object' } },
36
+ { name: 'schema', type: 'object', nullable: true },
37
+ { name: 'plan', type: 'object', nullable: true },
38
+ ],
39
+ errors: ['refused'],
40
+ } as const
41
+
42
+ async run() {
43
+ const { flags } = await this.parse(Upgrade)
44
+ const config = await siteConfig()
45
+ if (flags.plan) {
46
+ this.changedNothing = true
47
+ const plan = await ops.upgradePlan(config)
48
+ if (plan.steps.length === 0) this.say('No framework migration is pending.')
49
+ const printStep = (step: ops.PlannedStepResult, indent = '') => {
50
+ this.say(
51
+ `\n${indent}== ${step.name} (${step.kind}${step.release ? `, release ${step.release}` : ''})`,
52
+ )
53
+ if (step.unplannable) this.say(`${indent} unplannable: ${step.unplannable}`)
54
+ for (const sql of step.statements) this.say(`${indent} ${sql};`)
55
+ }
56
+ for (const step of plan.steps) printStep(step)
57
+ // After core's, in the order `bundles` lists them, because that is the order they run.
58
+ for (const bundle of plan.bundles) {
59
+ this.say(
60
+ `\nbundle ${bundle.id} (${bundle.name}): ${bundle.steps.length === 0 ? 'nothing pending' : `${bundle.steps.length} step(s)`}`,
61
+ )
62
+ for (const step of bundle.steps) printStep(step, ' ')
63
+ }
64
+ this.say(
65
+ plan.site.checked
66
+ ? `\nsite schema: ${plan.site.action} (${plan.site.classification})`
67
+ : '\nsite schema: checked once the framework migrations have run',
68
+ )
69
+ return {
70
+ action: 'plan',
71
+ changed: false,
72
+ backup: null,
73
+ frameworkApplied: [],
74
+ bundlesApplied: [],
75
+ schema: null,
76
+ plan,
77
+ }
78
+ }
79
+ const result = await ops.upgradeRun(config, {
80
+ force: flags.force,
81
+ set: parseSet(flags.set),
82
+ backupTaken: flags['backup-taken'],
83
+ })
84
+ if (result.backup.path) this.progress(` backup ${result.backup.path}`)
85
+ if (result.action === 'refused')
86
+ this.fail(
87
+ 'refused',
88
+ `upgrade refused: ${result.reasons.length} outstanding finding(s).`,
89
+ 'Run `crombolo upgrade check --fix`, resolve what needs a person, and try again.',
90
+ findingsOf(result.reasons),
91
+ )
92
+ if (result.action === 'nothing') {
93
+ this.say('Nothing to upgrade: the database is at this version.')
94
+ return {
95
+ action: result.action,
96
+ changed: false,
97
+ backup: result.backup.path ?? null,
98
+ frameworkApplied: [],
99
+ bundlesApplied: [],
100
+ schema: null,
101
+ plan: null,
102
+ }
103
+ }
104
+ for (const name of result.frameworkApplied) this.say(` applied ${name}`)
105
+ for (const bundle of result.bundlesApplied)
106
+ for (const name of bundle.applied) this.say(` applied [${bundle.id}] ${name}`)
107
+ this.say(
108
+ ` schema ${result.schema?.action} ${result.schema?.version ?? ''}+${result.schema?.revision ?? ''}`,
109
+ )
110
+ return {
111
+ action: result.action,
112
+ changed: true,
113
+ backup: result.backup.path ?? null,
114
+ frameworkApplied: result.frameworkApplied,
115
+ bundlesApplied: result.bundlesApplied,
116
+ schema: result.schema ?? null,
117
+ plan: null,
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,55 @@
1
+ import { connect, readLedger } from '@crombolo/data'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import { page, pageFlags, pagination } from '../../support/flags.ts'
4
+ import { siteConfig } from '../../support/site.ts'
5
+
6
+ export default class UpgradeLedger extends BaseCommand {
7
+ static override summary = 'print migration_history: every migration, sync and import applied here'
8
+ static override flags = { ...pageFlags(1000) }
9
+ static override spec = {
10
+ effects: 'read_only',
11
+ cardinality: 'unbounded',
12
+ pagination,
13
+ fieldsArg: '--fields',
14
+ outputFields: [
15
+ { name: 'id', type: 'integer' },
16
+ { name: 'name', type: 'string' },
17
+ { name: 'kind', type: 'string' },
18
+ { name: 'appliedAt', type: 'string' },
19
+ { name: 'durationMs', type: 'integer' },
20
+ { name: 'appliedBy', type: 'string', nullable: true },
21
+ { name: 'bundle', type: 'string', nullable: true },
22
+ { name: 'note', type: 'string', nullable: true },
23
+ ],
24
+ } as const
25
+
26
+ async run() {
27
+ const { flags } = await this.parse(UpgradeLedger)
28
+ const config = await siteConfig()
29
+ const db = await connect({ file: config.sqliteFile })
30
+ try {
31
+ const rows = await readLedger(db)
32
+ const items = rows.slice(flags.offset, flags.offset + flags.limit).map((row) => ({
33
+ id: row.id,
34
+ name: row.name,
35
+ kind: row.kind,
36
+ appliedAt: row.appliedAt,
37
+ durationMs: row.durationMs,
38
+ appliedBy: row.appliedBy ?? null,
39
+ bundle: row.bundle ?? null,
40
+ note: row.note ?? null,
41
+ }))
42
+ for (const row of items)
43
+ this.say(
44
+ ` ${row.appliedAt} ${row.kind.padEnd(8)} ${row.bundle ? `[${row.bundle}] ` : ''}${row.name} ${row.durationMs}ms${row.appliedBy ? ` by ${row.appliedBy}` : ''}${row.note ? ` (${row.note})` : ''}`,
45
+ )
46
+ if (flags.offset + items.length < rows.length)
47
+ this.say(
48
+ ` … ${rows.length - flags.offset - items.length} more: --offset ${flags.offset + items.length}`,
49
+ )
50
+ return page(items, rows.length, flags)
51
+ } finally {
52
+ await db.close()
53
+ }
54
+ }
55
+ }
@@ -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/main.ts ADDED
@@ -0,0 +1,23 @@
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 { classify, reportFailure } from './classify.ts'
9
+ import { exitCodeOf } from './errors.ts'
10
+
11
+ export async function main(dir: string, argv: string[] = process.argv.slice(2)): Promise<void> {
12
+ try {
13
+ await run(argv, dir)
14
+ await flush()
15
+ } catch (error) {
16
+ if (error instanceof Errors.ExitError) process.exit(error.oclif.exit ?? 0)
17
+ const failure = classify(error)
18
+ if (failure.kind === 'usage' && !failure.hint)
19
+ Object.assign(failure, { hint: '`crombolo --help` lists the commands.' })
20
+ reportFailure(failure, wantsJson(argv))
21
+ process.exit(exitCodeOf(failure.kind))
22
+ }
23
+ }
@@ -0,0 +1,20 @@
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
+ }
@@ -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
+ }
@@ -55,19 +55,28 @@ export async function discover(url: string, http: typeof fetch = fetch): Promise
55
55
  try {
56
56
  response = await http(`${origin}/.well-known/crombolo-remote`)
57
57
  } catch (error) {
58
- throw new Error(
59
- `${origin} could not be reached: ${error instanceof Error ? error.message : error}`,
60
- )
58
+ throw new RemoteError({
59
+ status: 0,
60
+ code: 'unreachable',
61
+ title: `${origin} could not be reached: ${error instanceof Error ? error.message : error}`,
62
+ })
61
63
  }
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.`)
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)
65
72
  const body = (await response.json()) as Discovery
66
- if (body.protocol !== 'crombolo-remote') throw new Error(`${origin} is not a crombolo remote.`)
73
+ if (body.protocol !== 'crombolo-remote') throw notARemote()
67
74
  if (!body.versions?.includes(PROTOCOL_VERSION))
68
- throw new Error(
69
- `${origin} speaks protocol ${body.versions?.join(', ') || 'none'}; this crombolo speaks ${PROTOCOL_VERSION}.`,
70
- )
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
+ })
71
80
  return body
72
81
  }
73
82
 
@@ -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'
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. */