@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/cli",
3
- "version": "0.14.4",
3
+ "version": "0.16.0",
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",
@@ -19,7 +19,8 @@
19
19
  "type": "module",
20
20
  "exports": {
21
21
  ".": "./src/index.ts",
22
- "./bin": "./bin/crombolo.ts"
22
+ "./bin": "./bin/crombolo.ts",
23
+ "./plugin": "./src/plugin.ts"
23
24
  },
24
25
  "bin": {
25
26
  "crombolo": "./bin/crombolo.ts"
@@ -28,17 +29,87 @@
28
29
  "src",
29
30
  "bin",
30
31
  "templates",
32
+ "oclif.manifest.json",
31
33
  "NOTICE"
32
34
  ],
33
35
  "publishConfig": {
34
36
  "access": "public"
35
37
  },
36
38
  "dependencies": {
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"
39
+ "@crombolo/api-management": "0.16.0",
40
+ "@crombolo/auth": "0.16.0",
41
+ "@crombolo/core": "0.16.0",
42
+ "@crombolo/data": "0.16.0",
43
+ "@crombolo/schema": "0.16.0",
44
+ "@crombolo/server": "0.16.0",
45
+ "@crombolo/transfer": "0.16.0",
46
+ "@oclif/core": "^5.1.8"
47
+ },
48
+ "oclif": {
49
+ "bin": "crombolo",
50
+ "commands": "./src/commands",
51
+ "topicSeparator": " ",
52
+ "helpOptions": {
53
+ "flagSortOrder": "none"
54
+ },
55
+ "topics": {
56
+ "admin": {
57
+ "description": "the administrator's account"
58
+ },
59
+ "assets": {
60
+ "description": "the stylesheets and scripts beside the components"
61
+ },
62
+ "bundle": {
63
+ "description": "bundles: install, check, and purge their tables"
64
+ },
65
+ "components": {
66
+ "description": "the components in components/: check, list, new"
67
+ },
68
+ "content": {
69
+ "description": "move content between environments as bundles, and publish it"
70
+ },
71
+ "dictionary": {
72
+ "description": "the dictionary as Umbraco .udt files"
73
+ },
74
+ "domains": {
75
+ "description": "the hostnames this site answers on (domains.toml)"
76
+ },
77
+ "import": {
78
+ "description": "replace this site with a whole-site file; import an Umbraco backup"
79
+ },
80
+ "maintenance": {
81
+ "description": "pause and resume editors' saves, cluster-wide"
82
+ },
83
+ "media": {
84
+ "description": "the media library in bulk: import a folder, export one; a pulled site's files"
85
+ },
86
+ "remote": {
87
+ "description": "the remotes this site pulls from and pushes to (remotes.toml)"
88
+ },
89
+ "types": {
90
+ "description": "schema-as-code: the content types, data types and languages in schema/"
91
+ },
92
+ "upgrade": {
93
+ "description": "framework migrations: check, plan, apply, ledger"
94
+ },
95
+ "users": {
96
+ "description": "the back-office users: unlock, enable, reset a password or a second factor, invite"
97
+ },
98
+ "api-client": {
99
+ "description": "the API client a site seeds from its environment"
100
+ },
101
+ "index": {
102
+ "description": "the search indexes: what they hold, and rebuilding them"
103
+ },
104
+ "cache": {
105
+ "description": "the published cache on every node"
106
+ },
107
+ "recycle-bin": {
108
+ "description": "what has been deleted, before it is gone for good"
109
+ }
110
+ },
111
+ "optionalPlugins": [
112
+ "@crombolo/cloud"
113
+ ]
43
114
  }
44
115
  }
package/src/base.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * What every crombolo command shares (story 150): the output flag, the failure
3
+ * envelope and exit codes, the audit entry, and the description `crombolo
4
+ * schema` publishes. A command's `run()` returns its result; in JSON mode that
5
+ * result is the output, and in text mode the command says it as it goes.
6
+ */
7
+ import { Command, Errors, Flags } from '@oclif/core'
8
+ import { CliError, type ErrorKind, exitCodeOf, OUTCOMES, type OutcomeName } from './errors.ts'
9
+ import type * as ops from './operations/commands.ts'
10
+ import type { Io } from './support/site.ts'
11
+
12
+ /** A field of one output record, in clispec's closed set of types. */
13
+ export interface OutputField {
14
+ name: string
15
+ type: 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array'
16
+ nullable?: boolean
17
+ description?: string
18
+ enum?: readonly unknown[]
19
+ fields?: readonly OutputField[]
20
+ items?: Omit<OutputField, 'name'>
21
+ }
22
+
23
+ /** The clispec v0.3 properties of a command, beyond its args and flags. */
24
+ export interface CommandSpec {
25
+ effects: 'read_only' | 'idempotent' | 'non_idempotent'
26
+ outputKind?: 'data' | 'stream' | 'opaque'
27
+ cardinality?: 'single' | 'bounded' | 'unbounded'
28
+ outputFields?: readonly OutputField[]
29
+ /** For a shape that depends on the input: `{}` says exactly that. */
30
+ stdoutSchema?: Record<string, unknown>
31
+ errors?: readonly ErrorKind[]
32
+ outcomes?: readonly OutcomeName[]
33
+ confirmationBypassArg?: string
34
+ requiresTty?: boolean
35
+ pagination?: { style: 'offset'; offsetArg: string; limitArg: string }
36
+ fieldsArg?: string
37
+ stability?: 'stable' | 'beta' | 'experimental' | 'deprecated'
38
+ }
39
+
40
+ export interface AuditSpec {
41
+ category: ops.CommandAudit['category']
42
+ action: string
43
+ entityType?: string
44
+ }
45
+
46
+ /** `-o json`, `-ojson`, `-o=json`, `--output json`, `--output=json` or `--json`, before any `--`. */
47
+ export function wantsJson(argv: readonly string[]): boolean {
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const arg = argv[i] as string
50
+ if (arg === '--') return false
51
+ if (arg === '--json' || arg === '-ojson' || arg === '-o=json' || arg === '--output=json')
52
+ return true
53
+ if ((arg === '-o' || arg === '--output') && argv[i + 1] === 'json') return true
54
+ }
55
+ return false
56
+ }
57
+
58
+ export abstract class BaseCommand extends Command implements Io {
59
+ static override baseFlags = {
60
+ output: Flags.option({
61
+ char: 'o',
62
+ options: ['text', 'json'] as const,
63
+ default: 'text' as const,
64
+ summary: 'text for people; json for scripts, on stdout',
65
+ helpGroup: 'GLOBAL',
66
+ })(),
67
+ json: Flags.boolean({ hidden: true, summary: 'the same as -o json' }),
68
+ }
69
+
70
+ static spec: CommandSpec
71
+ static audit?: AuditSpec
72
+
73
+ /** Set by a command whose check found what it reports: the exit code, with no envelope. */
74
+ protected outcome?: OutcomeName
75
+ /** The entity an audit entry names, when the command acted on one. */
76
+ protected auditKey?: string
77
+ /** A dry run or a plan changes nothing, so it writes no audit entry. */
78
+ protected changedNothing = false
79
+
80
+ override jsonEnabled(): boolean {
81
+ return wantsJson(this.argv)
82
+ }
83
+
84
+ protected override logJson(json: unknown): void {
85
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`)
86
+ }
87
+
88
+ say = (line = ''): void => {
89
+ this.log(line)
90
+ }
91
+
92
+ progress = (line: string): void => {
93
+ process.stderr.write(`${line}\n`)
94
+ }
95
+
96
+ warning = (line: string): void => {
97
+ process.stderr.write(`${line}\n`)
98
+ }
99
+
100
+ protected override async _run<T>(): Promise<T> {
101
+ const result = await super._run<T>()
102
+ if (this.outcome) this.exit(OUTCOMES[this.outcome].code)
103
+ return result
104
+ }
105
+
106
+ protected override async catch(error: Error): Promise<never> {
107
+ if (error instanceof Errors.ExitError) throw error
108
+ // Loaded when something fails, so a command — or a plugin's — that succeeds
109
+ // never pays for the site packages it knows how to name.
110
+ const { classify, reportFailure } = await import('./classify.ts')
111
+ const failure = classify(error)
112
+ reportFailure(failure, this.jsonEnabled())
113
+ throw new Errors.ExitError(exitCodeOf(failure.kind))
114
+ }
115
+
116
+ protected override async finally(error: Error | undefined): Promise<void> {
117
+ const audit = (this.constructor as typeof BaseCommand).audit
118
+ if (error || !audit || this.changedNothing) return
119
+ const [{ recordCommand }, { siteConfig }] = await Promise.all([
120
+ import('./operations/commands.ts'),
121
+ import('./support/site.ts'),
122
+ ])
123
+ await recordCommand(await siteConfig(), {
124
+ ...audit,
125
+ command: `crombolo ${(this.id ?? '').replaceAll(':', ' ')}`,
126
+ entityKey: this.auditKey,
127
+ })
128
+ }
129
+
130
+ /** A failure, as the command's own sentence. */
131
+ protected fail(kind: ErrorKind, message: string, hint?: string, details?: unknown): never {
132
+ throw new CliError(kind, message, { hint, details })
133
+ }
134
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Any error a command lets go of, as a `CliError`: the kind it is, so the exit
3
+ * code and the envelope follow from one table.
4
+ */
5
+ import { MigrationStepError, UpgradeStateError } from '@crombolo/data'
6
+ import { CommandError, PassphraseError } from '@crombolo/server'
7
+ import { CliError, ERROR_KINDS, type ErrorKind } from './errors.ts'
8
+ import { RemoteError, TimedOut } from './remote/client.ts'
9
+
10
+ const REMOTE_CODES: Record<string, ErrorKind> = {
11
+ 'sign-in-required': 'auth',
12
+ unreachable: 'unavailable',
13
+ 'not-a-remote': 'not_found',
14
+ }
15
+
16
+ function remoteKind(error: RemoteError): ErrorKind {
17
+ const { code, status } = error.problem
18
+ const known = REMOTE_CODES[code]
19
+ if (known) return known
20
+ if (status === 401) return 'auth'
21
+ if (status === 403) return 'refused'
22
+ if (status === 404) return 'not_found'
23
+ if (status === 409) return 'conflict'
24
+ if (status === 429 || status >= 500) return 'unavailable'
25
+ return 'refused'
26
+ }
27
+
28
+ const SYSTEM_CODES: Record<string, ErrorKind> = {
29
+ ENOENT: 'io',
30
+ EACCES: 'io',
31
+ EPERM: 'io',
32
+ ENOSPC: 'io',
33
+ EISDIR: 'io',
34
+ ENOTDIR: 'io',
35
+ ECONNREFUSED: 'unavailable',
36
+ ECONNRESET: 'unavailable',
37
+ ETIMEDOUT: 'unavailable',
38
+ ENOTFOUND: 'unavailable',
39
+ EAI_AGAIN: 'unavailable',
40
+ SQLITE_BUSY: 'conflict',
41
+ }
42
+
43
+ /**
44
+ * The parser's sentence without its advice: the note about a repeatable flag
45
+ * swallowing arguments does not apply, since every repeatable flag here takes
46
+ * one value each time, and the pointer to --help is the hint.
47
+ */
48
+ function parserMessage(message: string): string {
49
+ return (
50
+ message
51
+ .split(/\n\s*\nNote: /)[0]
52
+ ?.split('\n')
53
+ .filter((line) => !/^See more help with/.test(line))
54
+ .join('\n')
55
+ .trim() ?? message
56
+ )
57
+ }
58
+
59
+ export function classify(error: unknown): CliError {
60
+ if (error instanceof CliError) return error
61
+ if (error instanceof TimedOut)
62
+ return new CliError('timeout', error.message, {
63
+ hint: `The job carries on at the remote: \`crombolo remote job ${error.job.id}\` watches it again.`,
64
+ })
65
+ if (error instanceof RemoteError)
66
+ return new CliError(remoteKind(error), error.message, {
67
+ details: error.problem.items,
68
+ })
69
+ if (error instanceof PassphraseError)
70
+ return new CliError('auth', error.message, { details: error.problems })
71
+ if (error instanceof CommandError)
72
+ return new CliError('refused', error.message, {
73
+ details: error.problems.length > 0 ? error.problems : undefined,
74
+ })
75
+ if (error instanceof UpgradeStateError) return new CliError('refused', error.message)
76
+ if (error instanceof MigrationStepError) return new CliError('internal', error.message)
77
+ // The parser's own errors: an unknown flag, a missing argument, a bad value;
78
+ // and, before any command runs, an unknown command.
79
+ if (error instanceof Error && ('parse' in error || /^command .* not found$/.test(error.message)))
80
+ return new CliError('usage', parserMessage(error.message), {
81
+ hint: 'Pass --help to see what it takes.',
82
+ })
83
+ const code = (error as { code?: unknown } | null)?.code
84
+ if (typeof code === 'string' && SYSTEM_CODES[code])
85
+ return new CliError(SYSTEM_CODES[code], (error as Error).message)
86
+ return new CliError('internal', error instanceof Error ? error.message : String(error), {
87
+ details: process.env.CROMBOLO_DEBUG && error instanceof Error ? error.stack : undefined,
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Says a failure. In JSON mode stderr's last line is the envelope; in text mode
93
+ * a person reads sentences, and when stderr is not a terminal the envelope
94
+ * follows them as the last line, so a script reading a pipe can branch on it
95
+ * without asking for JSON.
96
+ */
97
+ export function reportFailure(failure: CliError, json: boolean): void {
98
+ if (!json) {
99
+ if (Array.isArray(failure.details))
100
+ for (const item of failure.details) process.stderr.write(` ${String(item)}\n`)
101
+ else if (typeof failure.details === 'string') process.stderr.write(`${failure.details}\n`)
102
+ process.stderr.write(`${failure.message}\n`)
103
+ if (failure.hint) process.stderr.write(` ${failure.hint}\n`)
104
+ if (process.stderr.isTTY) return
105
+ }
106
+ const kind = ERROR_KINDS[failure.kind]
107
+ process.stderr.write(
108
+ `${JSON.stringify({
109
+ error: {
110
+ kind: failure.kind,
111
+ message: failure.message,
112
+ ...(failure.hint ? { hint: failure.hint } : {}),
113
+ ...(failure.details !== undefined ? { details: failure.details } : {}),
114
+ exit_code: kind.exitCode,
115
+ ...('retryable' in kind ? { retryable: kind.retryable } : {}),
116
+ },
117
+ })}\n`,
118
+ )
119
+ }
package/src/clispec.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The CLI described as a clispec v0.3 document (https://clispec.dev), built
3
+ * from the loaded commands: their args and flags as oclif reads them, and the
4
+ * `spec` each declares. Nothing here touches a site, a network or a sign-in.
5
+ */
6
+ import type { Command } from '@oclif/core'
7
+ import type { CommandSpec, OutputField } from './base.ts'
8
+ import { ERROR_KINDS, OUTCOMES } from './errors.ts'
9
+
10
+ export const CLISPEC_VERSION = '0.3'
11
+
12
+ /** The flags every command takes, published once as `global_args`. */
13
+ const GLOBAL_FLAGS = new Set(['output', 'json'])
14
+
15
+ type Arg = Record<string, unknown>
16
+
17
+ function field(f: Omit<OutputField, 'name'> & { name?: string }): Record<string, unknown> {
18
+ return {
19
+ ...(f.name !== undefined ? { name: f.name } : {}),
20
+ type: f.type,
21
+ ...(f.nullable ? { nullable: true } : {}),
22
+ ...(f.enum ? { enum: f.enum } : {}),
23
+ ...(f.description ? { description: f.description } : {}),
24
+ ...(f.fields ? { fields: f.fields.map(field) } : {}),
25
+ ...(f.type === 'array' ? { items: field(f.items ?? { type: 'string' }) } : {}),
26
+ }
27
+ }
28
+
29
+ function flagArg(name: string, flag: Command.Flag.Cached): Arg {
30
+ const valued = flag.type === 'option'
31
+ const options = valued && 'options' in flag ? flag.options : undefined
32
+ const fallback = flag.default
33
+ const type = !valued
34
+ ? 'boolean'
35
+ : typeof fallback === 'number'
36
+ ? 'integer'
37
+ : flag.multiple
38
+ ? 'string[]'
39
+ : 'string'
40
+ return {
41
+ name: `--${name}`,
42
+ type,
43
+ ...(flag.char ? { short: `-${flag.char}` } : {}),
44
+ ...(flag.required ? { required: true } : {}),
45
+ ...(fallback !== undefined &&
46
+ typeof fallback !== 'function' &&
47
+ !(Array.isArray(fallback) && fallback.length === 0)
48
+ ? { default: fallback }
49
+ : {}),
50
+ ...(options ? { enum: [...options] } : {}),
51
+ ...(flag.summary || flag.description ? { description: flag.summary ?? flag.description } : {}),
52
+ }
53
+ }
54
+
55
+ function positional(name: string, arg: Command.Arg.Cached): Arg {
56
+ return {
57
+ name,
58
+ type: 'string',
59
+ ...(arg.required ? { required: true } : {}),
60
+ ...(arg.options ? { enum: [...arg.options] } : {}),
61
+ ...(arg.description ? { description: arg.description } : {}),
62
+ }
63
+ }
64
+
65
+ function describeCommand(command: Command.Cached): Record<string, unknown> {
66
+ const spec = command.spec as CommandSpec | undefined
67
+ if (!spec) throw new Error(`${command.id} declares no spec`)
68
+ const kind = spec.outputKind ?? 'data'
69
+ const args = [
70
+ ...Object.entries(command.args)
71
+ .filter(([, arg]) => !arg.hidden)
72
+ .map(([name, arg]) => positional(name, arg)),
73
+ ...Object.entries(command.flags)
74
+ .filter(([name, flag]) => !GLOBAL_FLAGS.has(name) && !flag.hidden)
75
+ .map(([name, flag]) => flagArg(name, flag)),
76
+ ]
77
+ const errors = [
78
+ ...new Set([
79
+ 'usage',
80
+ 'internal',
81
+ ...(spec.errors ?? []),
82
+ ...(spec.confirmationBypassArg ? ['confirmation_required'] : []),
83
+ ]),
84
+ ]
85
+ return {
86
+ name: command.id.replaceAll(':', ' '),
87
+ description: command.summary ?? command.description ?? command.id,
88
+ effects: spec.effects,
89
+ ...(kind !== 'data' ? { output_kind: kind } : {}),
90
+ ...(kind === 'data' ? { cardinality: spec.cardinality ?? 'single' } : {}),
91
+ ...(kind === 'data' && spec.outputFields
92
+ ? { output_fields: spec.outputFields.map(field) }
93
+ : {}),
94
+ ...(kind === 'data' && spec.stdoutSchema ? { stdout_schema: spec.stdoutSchema } : {}),
95
+ ...(kind === 'data' && !spec.outputFields && !spec.stdoutSchema ? { stdout_schema: {} } : {}),
96
+ ...(spec.pagination
97
+ ? {
98
+ pagination: {
99
+ style: spec.pagination.style,
100
+ offset_arg: spec.pagination.offsetArg,
101
+ limit_arg: spec.pagination.limitArg,
102
+ },
103
+ }
104
+ : {}),
105
+ ...(spec.fieldsArg ? { fields_arg: spec.fieldsArg } : {}),
106
+ ...(spec.confirmationBypassArg ? { confirmation_bypass_arg: spec.confirmationBypassArg } : {}),
107
+ ...(spec.requiresTty ? { requires_tty: true } : {}),
108
+ ...(spec.stability ? { stability: spec.stability } : {}),
109
+ ...(args.length > 0 ? { args } : {}),
110
+ errors,
111
+ ...(spec.outcomes?.length ? { outcomes: [...spec.outcomes] } : {}),
112
+ }
113
+ }
114
+
115
+ /** What the document is built from: a loaded oclif config, or the manifest's data. */
116
+ export interface CommandSource {
117
+ bin: string
118
+ version: string
119
+ commands: readonly Command.Cached[]
120
+ }
121
+
122
+ /** The whole CLI, or the commands under `path` (`content`, `content import`). */
123
+ export function clispecDocument(config: CommandSource, path: readonly string[] = []) {
124
+ const prefix = path.join(' ')
125
+ const commands = config.commands
126
+ .filter((command) => !command.hidden)
127
+ .map(describeCommand)
128
+ .filter((command) => {
129
+ const name = command.name as string
130
+ return prefix === '' || name === prefix || name.startsWith(`${prefix} `)
131
+ })
132
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)))
133
+ return {
134
+ clispec: CLISPEC_VERSION,
135
+ name: config.bin,
136
+ version: config.version,
137
+ description: 'The crombolo command line: a site, its schema, content, remotes and upgrades.',
138
+ output: { piped: 'text', tty: 'text' },
139
+ global_args: [
140
+ {
141
+ name: '--output',
142
+ short: '-o',
143
+ type: 'string',
144
+ enum: ['text', 'json'],
145
+ default: 'text',
146
+ description: 'text for people; json for scripts, on stdout',
147
+ },
148
+ { name: '--json', type: 'boolean', description: 'the same as -o json' },
149
+ ],
150
+ commands,
151
+ errors: Object.entries(ERROR_KINDS).map(([kind, e]) => ({
152
+ kind,
153
+ exit_code: e.exitCode,
154
+ ...('retryable' in e ? { retryable: e.retryable } : {}),
155
+ description: e.description,
156
+ })),
157
+ outcomes: Object.entries(OUTCOMES).map(([name, o]) => ({
158
+ code: o.code,
159
+ name,
160
+ description: o.description,
161
+ })),
162
+ }
163
+ }
@@ -0,0 +1,53 @@
1
+ import { AuthStore, resetAdminPassword } from '@crombolo/auth'
2
+ import { bootstrapDatabase } from '@crombolo/server'
3
+ import { Args, Flags } from '@oclif/core'
4
+ import { BaseCommand } from '../../base.ts'
5
+ import { readStdin } from '../../support/prompt.ts'
6
+ import { siteConfig } from '../../support/site.ts'
7
+
8
+ export default class AdminResetPassword extends BaseCommand {
9
+ static override summary = "reset the administrator's password and print it"
10
+ static override description =
11
+ 'A new one is generated unless --stdin gives it, so it never sits in shell history.'
12
+ static override examples = [
13
+ '<%= config.bin %> admin reset-password',
14
+ 'printf %s "$PASSWORD" | <%= config.bin %> admin reset-password --stdin',
15
+ ]
16
+ static override args = {
17
+ password: Args.string({ hidden: true, description: 'deprecated: use --stdin' }),
18
+ }
19
+ static override flags = {
20
+ stdin: Flags.boolean({ summary: 'read the new password from stdin' }),
21
+ }
22
+ static override audit = { category: 'user', action: 'ResetPassword', entityType: 'User' } as const
23
+ static override spec = {
24
+ effects: 'non_idempotent',
25
+ cardinality: 'single',
26
+ outputFields: [
27
+ { name: 'login', type: 'string' },
28
+ { name: 'password', type: 'string' },
29
+ ],
30
+ errors: ['not_found'],
31
+ } as const
32
+
33
+ async run() {
34
+ const { args, flags } = await this.parse(AdminResetPassword)
35
+ if (args.password)
36
+ this.warning(' deprecated a password on the command line: pipe it to --stdin instead')
37
+ const given = flags.stdin ? await readStdin() : args.password
38
+ const config = await siteConfig()
39
+ // The database, not the server: resetting a password has nothing to do with
40
+ // the schema sync, the caches or the port, and should not need them to be
41
+ // healthy to work.
42
+ const { db } = await bootstrapDatabase(config)
43
+ const result = await resetAdminPassword(
44
+ new AuthStore(db),
45
+ config.adminLogin,
46
+ given || undefined,
47
+ )
48
+ await db.close()
49
+ if (!result) this.fail('not_found', `No account '${config.adminLogin}'.`)
50
+ this.say(`${result.login}\n${result.password}`)
51
+ return { login: result.login, password: result.password }
52
+ }
53
+ }
@@ -0,0 +1,57 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { hashPassword } from '@crombolo/auth'
3
+ import { UserRepository } from '@crombolo/data'
4
+ import { bootstrapDatabase } from '@crombolo/server'
5
+ import { Flags } from '@oclif/core'
6
+ import { BaseCommand } from '../../base.ts'
7
+ import { siteConfig } from '../../support/site.ts'
8
+
9
+ /** A new secret for the API client a site seeds from its environment (story 034). */
10
+ export default class ApiClientRotate extends BaseCommand {
11
+ static override summary = 'issue a new secret for the API client, and print it once'
12
+ static override description =
13
+ 'The old secret stops working at once. Put the new one in CROMBOLO_API_CLIENT_SECRET: a start keeps the client it finds, so it never sets the secret back.'
14
+ static override flags = {
15
+ client: Flags.string({
16
+ summary: 'the client id; CROMBOLO_API_CLIENT_ID when not given',
17
+ helpValue: '<id>',
18
+ }),
19
+ }
20
+ static override audit = {
21
+ category: 'user',
22
+ action: 'RotateClientSecret',
23
+ entityType: 'User',
24
+ } as const
25
+ static override spec = {
26
+ effects: 'non_idempotent',
27
+ cardinality: 'single',
28
+ outputFields: [
29
+ { name: 'client', type: 'string' },
30
+ { name: 'secret', type: 'string' },
31
+ ],
32
+ errors: ['usage', 'not_found'],
33
+ } as const
34
+
35
+ async run() {
36
+ const { flags } = await this.parse(ApiClientRotate)
37
+ const config = await siteConfig()
38
+ const client = flags.client ?? config.apiClient?.id
39
+ if (!client)
40
+ this.fail('usage', 'Which API client?', 'Pass --client <id>, or set CROMBOLO_API_CLIENT_ID.')
41
+ const { db } = await bootstrapDatabase(config)
42
+ try {
43
+ const users = new UserRepository(db)
44
+ const owner = await users.clientCredentialOwner(client)
45
+ if (!owner) this.fail('not_found', `There is no API client "${client}".`)
46
+ this.auditKey = client
47
+ const secret = randomBytes(32).toString('base64url')
48
+ await users.removeClientCredential(owner.userId, client)
49
+ await users.addClientCredential(owner.userId, client, await hashPassword(secret))
50
+ this.say(` rotated ${client}; put this in CROMBOLO_API_CLIENT_SECRET:`)
51
+ this.say(secret)
52
+ return { client, secret }
53
+ } finally {
54
+ await db.close()
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,6 @@
1
+ import AssetsList from './list.ts'
2
+
3
+ /** `crombolo assets` on its own lists them. */
4
+ export default class Assets extends AssetsList {
5
+ static override hidden = true
6
+ }
@@ -0,0 +1,27 @@
1
+ import { listAssets } from '@crombolo/server'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import { here } from '../../support/describe.ts'
4
+ import { siteConfig } from '../../support/site.ts'
5
+
6
+ /** The stylesheets and scripts the backoffice also edits. */
7
+ export default class AssetsList extends BaseCommand {
8
+ static override summary = 'the stylesheets and scripts this site has'
9
+ static override spec = {
10
+ effects: 'read_only',
11
+ cardinality: 'bounded',
12
+ // One item of `items`, as clispec reads a collection.
13
+ outputFields: [
14
+ { name: 'kind', type: 'string' },
15
+ { name: 'path', type: 'string' },
16
+ ],
17
+ } as const
18
+
19
+ async run() {
20
+ await this.parse(this.ctor)
21
+ const found = listAssets(await siteConfig())
22
+ if (found.length === 0) this.say('No stylesheets or scripts here.')
23
+ const items = found.map((asset) => ({ kind: asset.kind, path: here(asset.path) }))
24
+ for (const asset of items) this.say(` ${asset.kind.padEnd(10)} ${asset.path}`)
25
+ return { items }
26
+ }
27
+ }