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