@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/cli",
3
- "version": "0.14.4",
3
+ "version": "0.15.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",
@@ -28,17 +28,68 @@
28
28
  "src",
29
29
  "bin",
30
30
  "templates",
31
+ "oclif.manifest.json",
31
32
  "NOTICE"
32
33
  ],
33
34
  "publishConfig": {
34
35
  "access": "public"
35
36
  },
36
37
  "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"
38
+ "@crombolo/auth": "0.15.0",
39
+ "@crombolo/core": "0.15.0",
40
+ "@crombolo/data": "0.15.0",
41
+ "@crombolo/schema": "0.15.0",
42
+ "@crombolo/server": "0.15.0",
43
+ "@crombolo/transfer": "0.15.0",
44
+ "@oclif/core": "^5.1.8"
45
+ },
46
+ "oclif": {
47
+ "bin": "crombolo",
48
+ "commands": "./src/commands",
49
+ "topicSeparator": " ",
50
+ "helpOptions": {
51
+ "flagSortOrder": "none"
52
+ },
53
+ "topics": {
54
+ "admin": {
55
+ "description": "the administrator's account"
56
+ },
57
+ "assets": {
58
+ "description": "the stylesheets and scripts beside the components"
59
+ },
60
+ "bundle": {
61
+ "description": "bundles: install, check, and purge their tables"
62
+ },
63
+ "components": {
64
+ "description": "the components in components/: check, list, new"
65
+ },
66
+ "content": {
67
+ "description": "move content between environments as bundles, and publish it"
68
+ },
69
+ "dictionary": {
70
+ "description": "the dictionary as Umbraco .udt files"
71
+ },
72
+ "domains": {
73
+ "description": "the hostnames this site answers on (domains.toml)"
74
+ },
75
+ "import": {
76
+ "description": "replace this site with a whole-site file; import an Umbraco backup"
77
+ },
78
+ "maintenance": {
79
+ "description": "pause and resume editors' saves, cluster-wide"
80
+ },
81
+ "media": {
82
+ "description": "a pulled site's media files"
83
+ },
84
+ "remote": {
85
+ "description": "the remotes this site pulls from and pushes to (remotes.toml)"
86
+ },
87
+ "types": {
88
+ "description": "schema-as-code: the content types, data types and languages in schema/"
89
+ },
90
+ "upgrade": {
91
+ "description": "framework migrations: check, plan, apply, ledger"
92
+ }
93
+ }
43
94
  }
44
95
  }
package/src/base.ts ADDED
@@ -0,0 +1,129 @@
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 { classify, reportFailure } from './classify.ts'
9
+ import { CliError, type ErrorKind, exitCodeOf, OUTCOMES, type OutcomeName } from './errors.ts'
10
+ import * as ops from './operations/commands.ts'
11
+ import type { Io } from './support/site.ts'
12
+ import { siteConfig } from './support/site.ts'
13
+
14
+ /** A field of one output record, in clispec's closed set of types. */
15
+ export interface OutputField {
16
+ name: string
17
+ type: 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array'
18
+ nullable?: boolean
19
+ description?: string
20
+ enum?: readonly unknown[]
21
+ fields?: readonly OutputField[]
22
+ items?: Omit<OutputField, 'name'>
23
+ }
24
+
25
+ /** The clispec v0.3 properties of a command, beyond its args and flags. */
26
+ export interface CommandSpec {
27
+ effects: 'read_only' | 'idempotent' | 'non_idempotent'
28
+ outputKind?: 'data' | 'stream' | 'opaque'
29
+ cardinality?: 'single' | 'bounded' | 'unbounded'
30
+ outputFields?: readonly OutputField[]
31
+ /** For a shape that depends on the input: `{}` says exactly that. */
32
+ stdoutSchema?: Record<string, unknown>
33
+ errors?: readonly ErrorKind[]
34
+ outcomes?: readonly OutcomeName[]
35
+ confirmationBypassArg?: string
36
+ requiresTty?: boolean
37
+ pagination?: { style: 'offset'; offsetArg: string; limitArg: string }
38
+ fieldsArg?: string
39
+ stability?: 'stable' | 'beta' | 'experimental' | 'deprecated'
40
+ }
41
+
42
+ export interface AuditSpec {
43
+ category: ops.CommandAudit['category']
44
+ action: string
45
+ entityType?: string
46
+ }
47
+
48
+ /** `-o json`, `-ojson`, `-o=json`, `--output json`, `--output=json` or `--json`, before any `--`. */
49
+ export function wantsJson(argv: readonly string[]): boolean {
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const arg = argv[i] as string
52
+ if (arg === '--') return false
53
+ if (arg === '--json' || arg === '-ojson' || arg === '-o=json' || arg === '--output=json')
54
+ return true
55
+ if ((arg === '-o' || arg === '--output') && argv[i + 1] === 'json') return true
56
+ }
57
+ return false
58
+ }
59
+
60
+ export abstract class BaseCommand extends Command implements Io {
61
+ static override baseFlags = {
62
+ output: Flags.option({
63
+ char: 'o',
64
+ options: ['text', 'json'] as const,
65
+ default: 'text' as const,
66
+ summary: 'text for people; json for scripts, on stdout',
67
+ helpGroup: 'GLOBAL',
68
+ })(),
69
+ json: Flags.boolean({ hidden: true, summary: 'the same as -o json' }),
70
+ }
71
+
72
+ static spec: CommandSpec
73
+ static audit?: AuditSpec
74
+
75
+ /** Set by a command whose check found what it reports: the exit code, with no envelope. */
76
+ protected outcome?: OutcomeName
77
+ /** The entity an audit entry names, when the command acted on one. */
78
+ protected auditKey?: string
79
+ /** A dry run or a plan changes nothing, so it writes no audit entry. */
80
+ protected changedNothing = false
81
+
82
+ override jsonEnabled(): boolean {
83
+ return wantsJson(this.argv)
84
+ }
85
+
86
+ protected override logJson(json: unknown): void {
87
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`)
88
+ }
89
+
90
+ say = (line = ''): void => {
91
+ this.log(line)
92
+ }
93
+
94
+ progress = (line: string): void => {
95
+ process.stderr.write(`${line}\n`)
96
+ }
97
+
98
+ warning = (line: string): void => {
99
+ process.stderr.write(`${line}\n`)
100
+ }
101
+
102
+ protected override async _run<T>(): Promise<T> {
103
+ const result = await super._run<T>()
104
+ if (this.outcome) this.exit(OUTCOMES[this.outcome].code)
105
+ return result
106
+ }
107
+
108
+ protected override async catch(error: Error): Promise<never> {
109
+ if (error instanceof Errors.ExitError) throw error
110
+ const failure = classify(error)
111
+ reportFailure(failure, this.jsonEnabled())
112
+ throw new Errors.ExitError(exitCodeOf(failure.kind))
113
+ }
114
+
115
+ protected override async finally(error: Error | undefined): Promise<void> {
116
+ const audit = (this.constructor as typeof BaseCommand).audit
117
+ if (error || !audit || this.changedNothing) return
118
+ await ops.recordCommand(await siteConfig(), {
119
+ ...audit,
120
+ command: `crombolo ${(this.id ?? '').replaceAll(':', ' ')}`,
121
+ entityKey: this.auditKey,
122
+ })
123
+ }
124
+
125
+ /** A failure, as the command's own sentence. */
126
+ protected fail(kind: ErrorKind, message: string, hint?: string, details?: unknown): never {
127
+ throw new CliError(kind, message, { hint, details })
128
+ }
129
+ }
@@ -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,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
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { join, relative } from 'node:path'
4
+ import { assetDirs, assetScaffold } from '@crombolo/server'
5
+ import { Args } from '@oclif/core'
6
+ import { BaseCommand } from '../../base.ts'
7
+ import { here } from '../../support/describe.ts'
8
+ import { siteConfig } from '../../support/site.ts'
9
+
10
+ export default class AssetsNew extends BaseCommand {
11
+ static override summary = 'write an empty stylesheet or script in the place the backoffice edits'
12
+ static override examples = ['<%= config.bin %> assets new stylesheet site.css']
13
+ static override args = {
14
+ kind: Args.string({
15
+ required: true,
16
+ options: ['stylesheet', 'script'],
17
+ description: 'which kind',
18
+ }),
19
+ name: Args.string({ required: true, description: 'its file name' }),
20
+ }
21
+ static override spec = {
22
+ effects: 'non_idempotent',
23
+ cardinality: 'single',
24
+ outputFields: [{ name: 'created', type: 'string' }],
25
+ errors: ['conflict'],
26
+ } as const
27
+
28
+ async run() {
29
+ const { args } = await this.parse(AssetsNew)
30
+ const kind = args.kind as 'stylesheet' | 'script'
31
+ const where = assetDirs(await siteConfig())[kind]
32
+ const file = join(
33
+ where.dir,
34
+ args.name.endsWith(where.extension) ? args.name : `${args.name}${where.extension}`,
35
+ )
36
+ if (existsSync(file)) this.fail('conflict', `${here(file)} already exists.`)
37
+ await mkdir(join(file, '..'), { recursive: true })
38
+ await writeFile(file, assetScaffold(kind, relative(where.dir, file)))
39
+ this.say(` created ${here(file)}`)
40
+ return { created: here(file) }
41
+ }
42
+ }