@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.
- package/bin/crombolo.ts +4 -3166
- package/oclif.manifest.json +6710 -0
- package/package.json +58 -7
- package/src/base.ts +129 -0
- package/src/classify.ts +119 -0
- package/src/clispec.ts +163 -0
- package/src/commands/admin/reset-password.ts +53 -0
- package/src/commands/assets/index.ts +6 -0
- package/src/commands/assets/list.ts +27 -0
- package/src/commands/assets/new.ts +42 -0
- package/src/commands/bundle/purge.ts +71 -0
- package/src/commands/components/check.ts +58 -0
- package/src/commands/components/index.ts +6 -0
- package/src/commands/components/list.ts +40 -0
- package/src/commands/components/new.ts +49 -0
- package/src/commands/content/check.ts +102 -0
- package/src/commands/content/export.ts +116 -0
- package/src/commands/content/import.ts +109 -0
- package/src/commands/content/publish.ts +21 -0
- package/src/commands/content/revert.ts +137 -0
- package/src/commands/content/runs.ts +62 -0
- package/src/commands/content/unpublish.ts +21 -0
- package/src/commands/dictionary/export.ts +49 -0
- package/src/commands/dictionary/import.ts +46 -0
- package/src/commands/domains/apply.ts +23 -0
- package/src/commands/domains/clear.ts +27 -0
- package/src/commands/domains/index.ts +6 -0
- package/src/commands/domains/list.ts +86 -0
- package/src/commands/domains/set.ts +55 -0
- package/src/commands/domains/undo.ts +29 -0
- package/src/commands/export.ts +100 -0
- package/src/commands/generate.ts +33 -0
- package/src/commands/import/index.ts +175 -0
- package/src/commands/import/umbraco/apply.ts +128 -0
- package/src/commands/import/umbraco/report.ts +56 -0
- package/src/commands/init.ts +76 -0
- package/src/commands/login.ts +38 -0
- package/src/commands/logout.ts +36 -0
- package/src/commands/maintenance/pause.ts +29 -0
- package/src/commands/maintenance/resume.ts +20 -0
- package/src/commands/maintenance/status.ts +34 -0
- package/src/commands/media/fetch.ts +62 -0
- package/src/commands/pull.ts +210 -0
- package/src/commands/push.ts +108 -0
- package/src/commands/remote/add.ts +78 -0
- package/src/commands/remote/job.ts +53 -0
- package/src/commands/remote/list.ts +41 -0
- package/src/commands/remote/remove.ts +26 -0
- package/src/commands/schema.ts +32 -0
- package/src/commands/start.ts +64 -0
- package/src/commands/status.ts +103 -0
- package/src/commands/types/add-property.ts +116 -0
- package/src/commands/types/check.ts +88 -0
- package/src/commands/types/export.ts +32 -0
- package/src/commands/types/new.ts +116 -0
- package/src/commands/types/purge.ts +65 -0
- package/src/commands/types/rewrite.ts +30 -0
- package/src/commands/types/sync.ts +66 -0
- package/src/commands/upgrade/check.ts +69 -0
- package/src/commands/upgrade/index.ts +120 -0
- package/src/commands/upgrade/ledger.ts +55 -0
- package/src/commands/whoami.ts +41 -0
- package/src/errors.ts +90 -0
- package/src/main.ts +23 -0
- package/src/manifest.ts +20 -0
- package/src/reference.ts +118 -0
- package/src/remote/client.ts +19 -10
- package/src/remote/remotes-file.ts +3 -2
- package/src/remote/transfer.ts +27 -11
- package/src/support/components.ts +7 -0
- package/src/support/describe.ts +179 -0
- package/src/support/domains.ts +89 -0
- package/src/support/flags.ts +157 -0
- package/src/support/prompt.ts +126 -0
- package/src/support/publish.ts +142 -0
- package/src/support/remote.ts +58 -0
- package/src/support/serve.ts +460 -0
- package/src/support/site.ts +26 -0
- package/src/support/types.ts +112 -0
- package/src/support/umbraco.ts +86 -0
- package/templates/basic/bundle/content/bundle.json +1 -1
- package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- 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.
|
|
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.
|
|
38
|
-
"@crombolo/core": "0.
|
|
39
|
-
"@crombolo/data": "0.
|
|
40
|
-
"@crombolo/schema": "0.
|
|
41
|
-
"@crombolo/server": "0.
|
|
42
|
-
"@crombolo/transfer": "0.
|
|
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
|
+
}
|
package/src/classify.ts
ADDED
|
@@ -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,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
|
+
}
|