@crombolo/cli 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/bin/crombolo.ts +4 -3166
  2. package/oclif.manifest.json +6710 -0
  3. package/package.json +58 -7
  4. package/src/base.ts +129 -0
  5. package/src/classify.ts +119 -0
  6. package/src/clispec.ts +163 -0
  7. package/src/commands/admin/reset-password.ts +53 -0
  8. package/src/commands/assets/index.ts +6 -0
  9. package/src/commands/assets/list.ts +27 -0
  10. package/src/commands/assets/new.ts +42 -0
  11. package/src/commands/bundle/purge.ts +71 -0
  12. package/src/commands/components/check.ts +58 -0
  13. package/src/commands/components/index.ts +6 -0
  14. package/src/commands/components/list.ts +40 -0
  15. package/src/commands/components/new.ts +49 -0
  16. package/src/commands/content/check.ts +102 -0
  17. package/src/commands/content/export.ts +116 -0
  18. package/src/commands/content/import.ts +109 -0
  19. package/src/commands/content/publish.ts +21 -0
  20. package/src/commands/content/revert.ts +137 -0
  21. package/src/commands/content/runs.ts +62 -0
  22. package/src/commands/content/unpublish.ts +21 -0
  23. package/src/commands/dictionary/export.ts +49 -0
  24. package/src/commands/dictionary/import.ts +46 -0
  25. package/src/commands/domains/apply.ts +23 -0
  26. package/src/commands/domains/clear.ts +27 -0
  27. package/src/commands/domains/index.ts +6 -0
  28. package/src/commands/domains/list.ts +86 -0
  29. package/src/commands/domains/set.ts +55 -0
  30. package/src/commands/domains/undo.ts +29 -0
  31. package/src/commands/export.ts +100 -0
  32. package/src/commands/generate.ts +33 -0
  33. package/src/commands/import/index.ts +175 -0
  34. package/src/commands/import/umbraco/apply.ts +128 -0
  35. package/src/commands/import/umbraco/report.ts +56 -0
  36. package/src/commands/init.ts +76 -0
  37. package/src/commands/login.ts +38 -0
  38. package/src/commands/logout.ts +36 -0
  39. package/src/commands/maintenance/pause.ts +29 -0
  40. package/src/commands/maintenance/resume.ts +20 -0
  41. package/src/commands/maintenance/status.ts +34 -0
  42. package/src/commands/media/fetch.ts +62 -0
  43. package/src/commands/pull.ts +210 -0
  44. package/src/commands/push.ts +108 -0
  45. package/src/commands/remote/add.ts +78 -0
  46. package/src/commands/remote/job.ts +53 -0
  47. package/src/commands/remote/list.ts +41 -0
  48. package/src/commands/remote/remove.ts +26 -0
  49. package/src/commands/schema.ts +32 -0
  50. package/src/commands/start.ts +64 -0
  51. package/src/commands/status.ts +103 -0
  52. package/src/commands/types/add-property.ts +116 -0
  53. package/src/commands/types/check.ts +88 -0
  54. package/src/commands/types/export.ts +32 -0
  55. package/src/commands/types/new.ts +116 -0
  56. package/src/commands/types/purge.ts +65 -0
  57. package/src/commands/types/rewrite.ts +30 -0
  58. package/src/commands/types/sync.ts +66 -0
  59. package/src/commands/upgrade/check.ts +69 -0
  60. package/src/commands/upgrade/index.ts +120 -0
  61. package/src/commands/upgrade/ledger.ts +55 -0
  62. package/src/commands/whoami.ts +41 -0
  63. package/src/errors.ts +90 -0
  64. package/src/main.ts +23 -0
  65. package/src/manifest.ts +20 -0
  66. package/src/reference.ts +118 -0
  67. package/src/remote/client.ts +19 -10
  68. package/src/remote/remotes-file.ts +3 -2
  69. package/src/remote/transfer.ts +27 -11
  70. package/src/support/components.ts +7 -0
  71. package/src/support/describe.ts +179 -0
  72. package/src/support/domains.ts +89 -0
  73. package/src/support/flags.ts +157 -0
  74. package/src/support/prompt.ts +126 -0
  75. package/src/support/publish.ts +142 -0
  76. package/src/support/remote.ts +58 -0
  77. package/src/support/serve.ts +460 -0
  78. package/src/support/site.ts +26 -0
  79. package/src/support/types.ts +112 -0
  80. package/src/support/umbraco.ts +86 -0
  81. package/templates/basic/bundle/content/bundle.json +1 -1
  82. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  83. package/templates/basic/bundle/package.json +1 -1
  84. package/templates/demo/harbourstone/bundle/package.json +1 -1
  85. package/templates/starter/bundle/package.json +1 -1
@@ -0,0 +1,62 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { mediaStoreFor, readRemoteMedia } from '@crombolo/server'
4
+ import { BaseCommand } from '../../base.ts'
5
+ import { discover, RemoteClient } from '../../remote/client.ts'
6
+ import { fetchReferencedMedia } from '../../remote/transfer.ts'
7
+ import { describeBytes } from '../../support/describe.ts'
8
+ import { keychain } from '../../support/remote.ts'
9
+ import { cwd, siteConfig } from '../../support/site.ts'
10
+
11
+ export default class MediaFetch extends BaseCommand {
12
+ static override summary = "a lazily pulled site's media files, all of them now"
13
+ static override description =
14
+ 'Exits 10 when a file could not be fetched; running it again fetches only what is missing.'
15
+ static override spec = {
16
+ effects: 'idempotent',
17
+ cardinality: 'single',
18
+ outputFields: [
19
+ { name: 'fetched', type: 'integer' },
20
+ { name: 'skipped', type: 'integer' },
21
+ { name: 'bytes', type: 'integer' },
22
+ { name: 'failed', type: 'array', items: { type: 'string' } },
23
+ ],
24
+ errors: ['not_found', 'auth', 'unavailable', 'io'],
25
+ } as const
26
+
27
+ async run() {
28
+ await this.parse(MediaFetch)
29
+ const source = readRemoteMedia(cwd())
30
+ const listed = join(cwd(), '.crombolo', 'remote-media-blobs.json')
31
+ if (!source || !existsSync(listed))
32
+ this.fail(
33
+ 'not_found',
34
+ 'This site was not pulled with --lazy-media: there is nothing to fetch.',
35
+ )
36
+ const client = new RemoteClient(await discover(source.api), keychain())
37
+ const blobs = JSON.parse(await Bun.file(listed).text()) as {
38
+ key: string
39
+ etag: string | null
40
+ }[]
41
+ const fetched = await fetchReferencedMedia(client, {
42
+ environment: source.environment,
43
+ blobs,
44
+ store: await mediaStoreFor(await siteConfig()),
45
+ siteDir: cwd(),
46
+ onProgress: (done, total) => {
47
+ if (done % 50 === 0 || done === total) this.progress(` ${done}/${total}`)
48
+ },
49
+ })
50
+ this.say(
51
+ ` media ${fetched.fetched} fetched (${describeBytes(fetched.bytes)}), ${fetched.skipped} already here`,
52
+ )
53
+ if (fetched.failed.length > 0)
54
+ this.fail(
55
+ 'io',
56
+ `${fetched.failed.length} file(s) could not be fetched.`,
57
+ 'Run it again to fetch them.',
58
+ fetched.failed,
59
+ )
60
+ return fetched
61
+ }
62
+ }
@@ -0,0 +1,210 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { mediaStoreFor, writeRemoteMedia } from '@crombolo/server'
4
+ import { Args, Flags } from '@oclif/core'
5
+ import { BaseCommand } from '../base.ts'
6
+ import * as ops from '../operations/commands.ts'
7
+ import { fetchReferencedMedia, pullFile, referencedBlobs } from '../remote/transfer.ts'
8
+ import {
9
+ counted,
10
+ describeBytes,
11
+ describeJob,
12
+ findingsOf,
13
+ printTransferFindings,
14
+ } from '../support/describe.ts'
15
+ import { backupTaken } from '../support/flags.ts'
16
+ import { confirm } from '../support/prompt.ts'
17
+ import { connectRemote, needEnvironment } from '../support/remote.ts'
18
+ import { serveSite, startFromFile } from '../support/serve.ts'
19
+ import { cwd, hasSite, siteConfig } from '../support/site.ts'
20
+
21
+ export default class Pull extends BaseCommand {
22
+ static override summary = 'a copy of a remote environment: started here, or replacing this site'
23
+ static override description = [
24
+ 'Sealed and anonymised unless --full. In an empty folder it starts a new site and serves it; in a site it replaces it, saying first what it loses (recycled, and asked about unless --yes).',
25
+ '--media-info-only fetches media files one at a time, only what changed since the last pull; --lazy-media fetches each the first time it is asked for; --no-media leaves them out.',
26
+ ].join('\n\n')
27
+ static override examples = ['<%= config.bin %> pull production --env staging --yes']
28
+ static override args = {
29
+ remote: Args.string({ description: 'the remote; the only one, when there is one' }),
30
+ }
31
+ static override flags = {
32
+ env: Flags.string({ summary: 'the environment to pull', helpValue: '<id>' }),
33
+ full: Flags.boolean({ summary: 'a full copy, people included: needs the permission for it' }),
34
+ yes: Flags.boolean({ summary: 'replace this site without asking' }),
35
+ 'no-media': Flags.boolean({ summary: 'leave the media files out' }),
36
+ 'media-info-only': Flags.boolean({
37
+ summary: 'fetch media files one at a time, only what changed',
38
+ }),
39
+ 'lazy-media': Flags.boolean({
40
+ summary: 'fetch each media file the first time it is asked for',
41
+ }),
42
+ timeout: Flags.integer({
43
+ summary: 'seconds to wait for the remote before giving up',
44
+ default: 1800,
45
+ min: 1,
46
+ }),
47
+ 'backup-taken': backupTaken,
48
+ }
49
+ static override spec = {
50
+ effects: 'non_idempotent',
51
+ cardinality: 'single',
52
+ confirmationBypassArg: '--yes',
53
+ outputFields: [
54
+ { name: 'environment', type: 'string' },
55
+ { name: 'size', type: 'integer' },
56
+ { name: 'anonymised', type: 'boolean' },
57
+ { name: 'started', type: 'boolean' },
58
+ { name: 'runId', type: 'string' },
59
+ { name: 'media', type: 'object', nullable: true },
60
+ {
61
+ name: 'serving',
62
+ type: 'object',
63
+ description: 'in an empty folder: where the new site answers',
64
+ },
65
+ ],
66
+ errors: [
67
+ 'usage',
68
+ 'auth',
69
+ 'not_found',
70
+ 'refused',
71
+ 'timeout',
72
+ 'unavailable',
73
+ 'confirmation_required',
74
+ ],
75
+ } as const
76
+
77
+ async run() {
78
+ const { args, flags } = await this.parse(Pull)
79
+ const { client, environment: env, discovery } = await connectRemote(args.remote, flags.env)
80
+ const environment = needEnvironment(env, discovery)
81
+ const media = flags['no-media']
82
+ ? ('omitted' as const)
83
+ : flags['media-info-only'] || flags['lazy-media']
84
+ ? ('referenced' as const)
85
+ : ('included' as const)
86
+ const incoming = join(cwd(), '.crombolo', 'transfer', 'incoming')
87
+ await mkdir(incoming, { recursive: true })
88
+ const out = join(incoming, `pull-${environment}-${Date.now().toString(36)}.tgz`)
89
+ const pulled = await pullFile(client, {
90
+ environment,
91
+ out,
92
+ full: flags.full,
93
+ media,
94
+ onProgress: (job) => this.progress(describeJob(job)),
95
+ timeoutMs: flags.timeout * 1000,
96
+ })
97
+ this.progress(` pulled ${describeBytes(pulled.size)}${flags.full ? '' : ', anonymised'}`)
98
+
99
+ const existing = hasSite()
100
+ let runId: string
101
+ if (!existing) {
102
+ const started = await startFromFile(this, out, {
103
+ postgres: false,
104
+ install: true,
105
+ allowMissingMedia: media !== 'included',
106
+ passphrase: pulled.passphrase,
107
+ })
108
+ runId = started.runId
109
+ } else {
110
+ const config = await siteConfig()
111
+ const { summary } = await ops.siteReplaceCheck(config, {
112
+ bundle: out,
113
+ passphrase: pulled.passphrase,
114
+ replace: 'replace',
115
+ allowMissingBlobs: media !== 'included',
116
+ })
117
+ if (Object.keys(summary.leaving).length > 0)
118
+ this.say(` recycled ${counted(summary.leaving)} here, not in ${environment}`)
119
+ if (summary.filesRemoved.length > 0)
120
+ this.say(` removes ${summary.filesRemoved.length} file(s) here, kept for a revert`)
121
+ if (!flags.yes)
122
+ await confirm(` Replace this site with ${environment}? [y/N]`, { bypass: '--yes' })
123
+ const result = await ops.contentImport(config, {
124
+ bundle: out,
125
+ passphrase: pulled.passphrase,
126
+ replace: 'replace',
127
+ allowMissingBlobs: media !== 'included',
128
+ label: `pull from ${environment}`,
129
+ backupTaken: flags['backup-taken'],
130
+ })
131
+ for (const problem of result.problems) this.warning(` ${problem}`)
132
+ if (!result.runId) {
133
+ printTransferFindings(this, result.check)
134
+ this.fail(
135
+ 'refused',
136
+ 'pull refused. Nothing was changed.',
137
+ undefined,
138
+ findingsOf(result.check.outstanding),
139
+ )
140
+ }
141
+ runId = result.runId
142
+ this.say(` replaced, run ${runId}: \`crombolo content revert ${runId}\` puts it back`)
143
+ }
144
+
145
+ let fetched: {
146
+ fetched: number
147
+ skipped: number
148
+ bytes: number
149
+ failed: string[]
150
+ lazy: boolean
151
+ } | null = null
152
+ if (media === 'referenced') {
153
+ const config = await siteConfig()
154
+ const blobs = await referencedBlobs(out)
155
+ if (flags['lazy-media']) {
156
+ writeRemoteMedia(cwd(), { api: discovery.api, environment })
157
+ await writeFile(
158
+ join(cwd(), '.crombolo', 'remote-media-blobs.json'),
159
+ `${JSON.stringify(
160
+ blobs.map((b) => ({ key: b.key, etag: b.etag })),
161
+ null,
162
+ 2,
163
+ )}\n`,
164
+ )
165
+ this.say(
166
+ ` media ${blobs.length} file(s) fetched from ${environment} as they are first asked for`,
167
+ )
168
+ fetched = { fetched: 0, skipped: 0, bytes: 0, failed: [], lazy: true }
169
+ } else {
170
+ const result = await fetchReferencedMedia(client, {
171
+ environment,
172
+ blobs,
173
+ store: await mediaStoreFor(config),
174
+ siteDir: cwd(),
175
+ })
176
+ this.say(
177
+ ` media ${result.fetched} fetched (${describeBytes(result.bytes)}), ${result.skipped} already here`,
178
+ )
179
+ for (const key of result.failed)
180
+ this.warning(` missing ${key}: run the pull again to fetch it`)
181
+ fetched = { ...result, lazy: false }
182
+ }
183
+ }
184
+ const summary = {
185
+ environment,
186
+ size: pulled.size,
187
+ anonymised: !flags.full,
188
+ started: !existing,
189
+ runId,
190
+ media: fetched,
191
+ }
192
+ if (!existing) {
193
+ // A new site serves, as `start --from` does, so its one record is written before it does.
194
+ const serving = await serveSite(this, {
195
+ keepAdmin: true,
196
+ bundles: [],
197
+ backupTaken: false,
198
+ resolve: [],
199
+ 'allow-missing-blobs': false,
200
+ publish: false,
201
+ 'no-schema': false,
202
+ 'no-files': false,
203
+ 'replace-files': false,
204
+ })
205
+ if (this.jsonEnabled()) process.stdout.write(`${JSON.stringify({ ...summary, serving })}\n`)
206
+ return
207
+ }
208
+ return summary
209
+ }
210
+ }
@@ -0,0 +1,108 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import { BaseCommand } from '../base.ts'
3
+ import { pushSite } from '../remote/transfer.ts'
4
+ import { counted, describeJob } from '../support/describe.ts'
5
+ import { confirm } from '../support/prompt.ts'
6
+ import { connectRemote, needEnvironment, openInBrowser } from '../support/remote.ts'
7
+ import { siteConfig } from '../support/site.ts'
8
+
9
+ export default class Push extends BaseCommand {
10
+ static override summary = 'replace a remote environment with this site'
11
+ static override description = [
12
+ 'Shows what it would replace first; --dry-run stops there.',
13
+ "A protected environment needs --force and its name typed back (--auto-approve skips that), and the remote may also hold it for someone else's approval.",
14
+ ].join('\n\n')
15
+ static override examples = [
16
+ '<%= config.bin %> push production --env staging --dry-run',
17
+ '<%= config.bin %> push production --env live --force --auto-approve',
18
+ ]
19
+ static override args = {
20
+ remote: Args.string({ description: 'the remote; the only one, when there is one' }),
21
+ }
22
+ static override flags = {
23
+ env: Flags.string({ summary: 'the environment to replace', helpValue: '<id>' }),
24
+ force: Flags.boolean({ summary: 'go on to a protected environment' }),
25
+ 'auto-approve': Flags.boolean({ summary: "with --force: don't ask for its name" }),
26
+ 'media-info-only': Flags.boolean({
27
+ summary: 'send media files one at a time, only what the remote lacks',
28
+ }),
29
+ 'dry-run': Flags.boolean({ summary: 'say what it would replace, and send nothing' }),
30
+ timeout: Flags.integer({
31
+ summary: 'seconds to wait for the remote before giving up',
32
+ default: 1800,
33
+ min: 1,
34
+ }),
35
+ 'no-browser': Flags.boolean({ summary: "don't open an approval page" }),
36
+ }
37
+ static override spec = {
38
+ effects: 'non_idempotent',
39
+ cardinality: 'single',
40
+ confirmationBypassArg: '--auto-approve',
41
+ outputFields: [
42
+ { name: 'environment', type: 'string' },
43
+ { name: 'check', type: 'object' },
44
+ { name: 'job', type: 'object', nullable: true },
45
+ ],
46
+ errors: [
47
+ 'usage',
48
+ 'auth',
49
+ 'not_found',
50
+ 'refused',
51
+ 'timeout',
52
+ 'unavailable',
53
+ 'confirmation_required',
54
+ ],
55
+ } as const
56
+
57
+ async run() {
58
+ const { args, flags } = await this.parse(Push)
59
+ const { client, environment: env, discovery } = await connectRemote(args.remote, flags.env)
60
+ const environment = needEnvironment(env, discovery)
61
+ if (flags['dry-run']) this.changedNothing = true
62
+ const { check, job } = await pushSite(await siteConfig(), client, {
63
+ environment,
64
+ force: flags.force,
65
+ autoApprove: flags['auto-approve'],
66
+ mediaInfoOnly: flags['media-info-only'],
67
+ dryRun: flags['dry-run'],
68
+ timeoutMs: flags.timeout * 1000,
69
+ onCheck: (c) => {
70
+ this.say(
71
+ ` sending ${counted(c.sending.counts, 'no content')}, ${c.sending.files} file(s)`,
72
+ )
73
+ if (c.fromRemote) {
74
+ if (Object.keys(c.recycles).length > 0)
75
+ this.say(` recycles ${counted(c.recycles)} on ${environment}`)
76
+ if (c.removesFiles.length > 0)
77
+ this.say(` removes ${c.removesFiles.join(', ')} on ${environment}`)
78
+ }
79
+ },
80
+ confirm: async () => {
81
+ await confirm(` This replaces ${environment}. Type its name to go on:`, {
82
+ bypass: '--auto-approve',
83
+ answer: environment,
84
+ declined: 'Nothing was sent.',
85
+ })
86
+ return true
87
+ },
88
+ onProgress: (j) => this.progress(describeJob(j)),
89
+ onApproval: (j) => {
90
+ this.progress(` waiting for someone else to approve it: ${j.approvalUrl}`)
91
+ if (j.approvalUrl) openInBrowser(j.approvalUrl, flags['no-browser'])
92
+ },
93
+ })
94
+ if (!job) {
95
+ this.say('\n Nothing was sent: that is what a push would replace.')
96
+ return { environment, check, job: null }
97
+ }
98
+ if (job.state !== 'done')
99
+ this.fail(
100
+ 'refused',
101
+ job.problem?.title ?? `the push ended ${job.state}`,
102
+ undefined,
103
+ job.problem?.items,
104
+ )
105
+ this.say(`\n pushed ${environment} replaced, run ${job.runId ?? '—'}`)
106
+ return { environment, check, job }
107
+ }
108
+ }
@@ -0,0 +1,78 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import { discover } from '../../remote/client.ts'
4
+ import { addRemote, readRemotes } from '../../remote/remotes-file.ts'
5
+ import { cwd } from '../../support/site.ts'
6
+
7
+ export default class RemoteAdd extends BaseCommand {
8
+ static override summary = 'a remote this site pulls from and pushes to, kept in remotes.toml'
9
+ static override description =
10
+ 'URLs only: sign-ins are kept in your keychain. Adding the same remote again changes nothing; a different URL for a name already taken needs --force.'
11
+ static override examples = [
12
+ '<%= config.bin %> remote add production https://example.com --env production',
13
+ ]
14
+ static override args = {
15
+ name: Args.string({
16
+ required: true,
17
+ description: 'a name for it: lower case letters, digits and dashes',
18
+ }),
19
+ url: Args.string({ required: true, description: "the site's URL" }),
20
+ }
21
+ static override flags = {
22
+ env: Flags.string({ summary: 'the environment it goes to by default', helpValue: '<id>' }),
23
+ force: Flags.boolean({ summary: 'replace a remote of the same name' }),
24
+ }
25
+ static override spec = {
26
+ effects: 'idempotent',
27
+ cardinality: 'single',
28
+ outputFields: [
29
+ { name: 'name', type: 'string' },
30
+ { name: 'url', type: 'string' },
31
+ { name: 'site', type: 'string' },
32
+ { name: 'changed', type: 'boolean' },
33
+ {
34
+ name: 'environments',
35
+ type: 'array',
36
+ items: {
37
+ type: 'object',
38
+ fields: [
39
+ { name: 'id', type: 'string' },
40
+ { name: 'protected', type: 'boolean' },
41
+ ],
42
+ },
43
+ },
44
+ ],
45
+ errors: ['usage', 'conflict', 'unavailable', 'not_found'],
46
+ } as const
47
+
48
+ async run() {
49
+ const { args, flags } = await this.parse(RemoteAdd)
50
+ const origin = new URL(args.url).origin
51
+ const entry = { name: args.name, url: origin, environment: flags.env }
52
+ const existing = readRemotes(cwd()).find((remote) => remote.name === args.name)
53
+ const same = existing?.url === origin && existing.environment === flags.env
54
+ if (existing && !same && !flags.force)
55
+ this.fail(
56
+ 'conflict',
57
+ `There is already a remote "${args.name}": ${existing.url}${existing.environment ? ` (env ${existing.environment})` : ''}.`,
58
+ 'Pass --force to replace it, or choose another name.',
59
+ )
60
+ const discovery = await discover(args.url)
61
+ if (!same) {
62
+ try {
63
+ addRemote(cwd(), entry)
64
+ } catch (error) {
65
+ this.fail('usage', error instanceof Error ? error.message : String(error))
66
+ }
67
+ }
68
+ this.say(` ${same ? 'exists ' : 'added '} ${args.name} ${discovery.name} (${origin})`)
69
+ const environments = discovery.environments.map((env) => ({
70
+ id: env.id,
71
+ protected: env.protected === true,
72
+ }))
73
+ for (const env of environments)
74
+ this.say(` env ${env.id}${env.protected ? ' protected' : ''}`)
75
+ this.say(`\n\`crombolo login ${args.name}\` signs in.`)
76
+ return { name: args.name, url: origin, site: discovery.name, changed: !same, environments }
77
+ }
78
+ }
@@ -0,0 +1,53 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import { describeJob } from '../../support/describe.ts'
4
+ import { connectRemote } from '../../support/remote.ts'
5
+
6
+ export default class RemoteJob extends BaseCommand {
7
+ static override summary = 'watch a pull or push job again, after a wait gave up'
8
+ static override description =
9
+ "Exits 0 when the job is done, and with the job's own failure otherwise."
10
+ static override args = {
11
+ id: Args.string({ required: true, description: 'the job id the wait printed' }),
12
+ remote: Args.string({ description: 'the remote; the only one, when there is one' }),
13
+ }
14
+ static override flags = {
15
+ timeout: Flags.integer({ summary: 'seconds to wait before giving up', default: 1800, min: 1 }),
16
+ }
17
+ static override spec = {
18
+ effects: 'read_only',
19
+ cardinality: 'single',
20
+ outputFields: [
21
+ { name: 'id', type: 'string' },
22
+ { name: 'kind', type: 'string' },
23
+ { name: 'state', type: 'string' },
24
+ { name: 'environment', type: 'string' },
25
+ { name: 'runId', type: 'string', nullable: true },
26
+ ],
27
+ errors: ['timeout', 'not_found', 'auth', 'refused', 'unavailable'],
28
+ } as const
29
+
30
+ async run() {
31
+ const { args, flags } = await this.parse(RemoteJob)
32
+ const { client } = await connectRemote(args.remote, undefined)
33
+ const job = await client.settle(args.id, {
34
+ onProgress: (j) => this.progress(describeJob(j)),
35
+ timeoutMs: flags.timeout * 1000,
36
+ })
37
+ if (job.state !== 'done')
38
+ this.fail(
39
+ 'refused',
40
+ job.problem?.title ?? `The ${job.kind} ended ${job.state}.`,
41
+ undefined,
42
+ job.problem?.items,
43
+ )
44
+ this.say(` ${job.kind} ${job.state}${job.runId ? `, run ${job.runId}` : ''}`)
45
+ return {
46
+ id: job.id,
47
+ kind: job.kind,
48
+ state: job.state,
49
+ environment: job.environment,
50
+ runId: job.runId ?? null,
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,41 @@
1
+ import { readSignIn } from '@crombolo/server'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import { readRemotes } from '../../remote/remotes-file.ts'
4
+ import { keychain } from '../../support/remote.ts'
5
+ import { cwd } from '../../support/site.ts'
6
+
7
+ export default class RemoteList extends BaseCommand {
8
+ static override summary = 'the remotes in remotes.toml, and whether you are signed in to each'
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: 'name', type: 'string' },
15
+ { name: 'url', type: 'string' },
16
+ { name: 'environment', type: 'string', nullable: true },
17
+ { name: 'signedIn', type: 'boolean' },
18
+ ],
19
+ } as const
20
+
21
+ async run() {
22
+ await this.parse(RemoteList)
23
+ const remotes = readRemotes(cwd())
24
+ if (remotes.length === 0) this.say(' No remotes yet: `crombolo remote add <name> <url>`.')
25
+ const items = []
26
+ for (const remote of remotes) {
27
+ const signedIn =
28
+ (await readSignIn(keychain(), remote.url).catch(() => undefined)) !== undefined
29
+ this.say(
30
+ ` ${remote.name.padEnd(12)} ${remote.url}${remote.environment ? ` env ${remote.environment}` : ''}${signedIn ? '' : ' (not signed in)'}`,
31
+ )
32
+ items.push({
33
+ name: remote.name,
34
+ url: remote.url,
35
+ environment: remote.environment ?? null,
36
+ signedIn,
37
+ })
38
+ }
39
+ return { items }
40
+ }
41
+ }
@@ -0,0 +1,26 @@
1
+ import { Args } from '@oclif/core'
2
+ import { BaseCommand } from '../../base.ts'
3
+ import { removeRemote } from '../../remote/remotes-file.ts'
4
+ import { cwd } from '../../support/site.ts'
5
+
6
+ export default class RemoteRemove extends BaseCommand {
7
+ static override summary = 'forget a remote; removing one that is not there changes nothing'
8
+ static override args = { name: Args.string({ required: true, description: "the remote's name" }) }
9
+ static override spec = {
10
+ effects: 'idempotent',
11
+ cardinality: 'single',
12
+ outputFields: [
13
+ { name: 'name', type: 'string' },
14
+ { name: 'changed', type: 'boolean' },
15
+ ],
16
+ } as const
17
+
18
+ async run() {
19
+ const { args } = await this.parse(RemoteRemove)
20
+ const changed = removeRemote(cwd(), args.name)
21
+ this.say(
22
+ changed ? ` removed ${args.name}` : ` There is no remote "${args.name}"; nothing changed.`,
23
+ )
24
+ return { name: args.name, changed }
25
+ }
26
+ }
@@ -0,0 +1,32 @@
1
+ import { BaseCommand } from '../base.ts'
2
+ import { clispecDocument } from '../clispec.ts'
3
+
4
+ export default class Schema extends BaseCommand {
5
+ static override summary = 'this CLI described for machines, as a clispec v0.3 document'
6
+ static override description = [
7
+ 'Every command, its arguments, what it changes, what it prints and how it fails (https://clispec.dev). Name a command to narrow it. It reads no site, no network and no sign-in.',
8
+ 'Until the next release, `schema check`, `schema sync` and the rest still run the `types` commands they were renamed to.',
9
+ ].join('\n\n')
10
+ static override examples = ['<%= config.bin %> schema', '<%= config.bin %> schema content import']
11
+ static override strict = false
12
+ static override usage = 'schema [COMMAND...]'
13
+ static override spec = {
14
+ effects: 'read_only',
15
+ cardinality: 'single',
16
+ stdoutSchema: { $ref: 'https://clispec.dev/schema/v0.3.json' },
17
+ errors: ['not_found'],
18
+ } as const
19
+
20
+ async run() {
21
+ const { argv } = await this.parse(Schema)
22
+ const path = argv.filter(
23
+ (arg): arg is string => typeof arg === 'string' && !arg.startsWith('-'),
24
+ )
25
+ const document = clispecDocument(this.config, path)
26
+ if (document.commands.length === 0)
27
+ this.fail('not_found', `There is no command "${path.join(' ')}".`)
28
+ // The document is the output in either mode: JSON is what it is.
29
+ if (!this.jsonEnabled()) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`)
30
+ return document
31
+ }
32
+ }
@@ -0,0 +1,64 @@
1
+ import { Flags } from '@oclif/core'
2
+ import { BaseCommand } from '../base.ts'
3
+ import { backupTaken, many, transferFlags } from '../support/flags.ts'
4
+ import { serveSite, startFromFile } from '../support/serve.ts'
5
+
6
+ export default class Start extends BaseCommand {
7
+ static override summary = 'boot the site in the current directory'
8
+ static override description = [
9
+ "In development it resets and prints the administrator's password on every start; --keep-admin leaves it alone.",
10
+ "--bundle applies a bundle's content first, once per bundle, taking the `content import` flags.",
11
+ "--from, in an empty folder: scaffold a site, install it, import a whole-site file (`crombolo export --site`) and serve it; the new administrator's password is printed once.",
12
+ 'It serves until stopped. With -o json it prints one object saying where it answers.',
13
+ ].join('\n\n')
14
+ static override examples = [
15
+ '<%= config.bin %> start',
16
+ '<%= config.bin %> start --bundle ./bundles/demo --publish',
17
+ '<%= config.bin %> start --from site-2026-10-01.tgz',
18
+ ]
19
+ static override flags = {
20
+ 'keep-admin': Flags.boolean({ summary: "don't touch the administrator's password" }),
21
+ bundle: many("apply a bundle's content first; repeatable", '<pkg>'),
22
+ from: Flags.string({
23
+ summary: 'in an empty folder: a site from a whole-site file',
24
+ helpValue: '<file>',
25
+ }),
26
+ postgres: Flags.boolean({ summary: 'with --from: configure Postgres' }),
27
+ name: Flags.string({ summary: "with --from: the site's name", helpValue: '<site name>' }),
28
+ 'no-install': Flags.boolean({ summary: 'with --from: skip `bun install`' }),
29
+ 'allow-missing-media': Flags.boolean({
30
+ summary: 'with --from: import media items whose files the file does not carry',
31
+ }),
32
+ label: Flags.string({ summary: "a label for a bundle's import run", helpValue: '<text>' }),
33
+ 'backup-taken': backupTaken,
34
+ ...transferFlags,
35
+ }
36
+ static override spec = {
37
+ effects: 'non_idempotent',
38
+ outputKind: 'stream',
39
+ errors: ['not_found', 'conflict', 'refused', 'unavailable', 'auth'],
40
+ } as const
41
+
42
+ async run() {
43
+ const { flags } = await this.parse(Start)
44
+ let keepAdmin = flags['keep-admin']
45
+ if (flags.from) {
46
+ await startFromFile(this, flags.from, {
47
+ name: flags.name,
48
+ postgres: flags.postgres,
49
+ install: !flags['no-install'],
50
+ allowMissingMedia: flags['allow-missing-media'],
51
+ })
52
+ // The password printed above is the one to use: this first start leaves it alone.
53
+ keepAdmin = true
54
+ }
55
+ const started = await serveSite(this, {
56
+ ...flags,
57
+ keepAdmin,
58
+ bundles: flags.bundle,
59
+ backupTaken: flags['backup-taken'],
60
+ })
61
+ // A stream: one record when it is up, then it serves until it is stopped.
62
+ if (this.jsonEnabled()) process.stdout.write(`${JSON.stringify(started)}\n`)
63
+ }
64
+ }