@crombolo/cli 0.14.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/bin/crombolo.ts +4 -3166
  2. package/oclif.manifest.json +8077 -0
  3. package/package.json +79 -8
  4. package/src/base.ts +134 -0
  5. package/src/classify.ts +119 -0
  6. package/src/clispec.ts +163 -0
  7. package/src/commands/admin/reset-password.ts +53 -0
  8. package/src/commands/api-client/rotate.ts +57 -0
  9. package/src/commands/assets/index.ts +6 -0
  10. package/src/commands/assets/list.ts +27 -0
  11. package/src/commands/assets/new.ts +42 -0
  12. package/src/commands/bundle/purge.ts +71 -0
  13. package/src/commands/cache/rebuild.ts +56 -0
  14. package/src/commands/components/check.ts +58 -0
  15. package/src/commands/components/index.ts +6 -0
  16. package/src/commands/components/list.ts +40 -0
  17. package/src/commands/components/new.ts +49 -0
  18. package/src/commands/content/check.ts +102 -0
  19. package/src/commands/content/export.ts +116 -0
  20. package/src/commands/content/import.ts +109 -0
  21. package/src/commands/content/publish.ts +21 -0
  22. package/src/commands/content/revert.ts +137 -0
  23. package/src/commands/content/runs.ts +62 -0
  24. package/src/commands/content/unpublish.ts +21 -0
  25. package/src/commands/dictionary/export.ts +49 -0
  26. package/src/commands/dictionary/import.ts +46 -0
  27. package/src/commands/domains/apply.ts +23 -0
  28. package/src/commands/domains/clear.ts +27 -0
  29. package/src/commands/domains/index.ts +6 -0
  30. package/src/commands/domains/list.ts +86 -0
  31. package/src/commands/domains/set.ts +55 -0
  32. package/src/commands/domains/undo.ts +29 -0
  33. package/src/commands/export.ts +100 -0
  34. package/src/commands/generate.ts +33 -0
  35. package/src/commands/health.ts +90 -0
  36. package/src/commands/import/index.ts +175 -0
  37. package/src/commands/import/umbraco/apply.ts +128 -0
  38. package/src/commands/import/umbraco/report.ts +56 -0
  39. package/src/commands/index/list.ts +45 -0
  40. package/src/commands/index/rebuild.ts +66 -0
  41. package/src/commands/init.ts +76 -0
  42. package/src/commands/login.ts +38 -0
  43. package/src/commands/logout.ts +36 -0
  44. package/src/commands/maintenance/pause.ts +29 -0
  45. package/src/commands/maintenance/resume.ts +20 -0
  46. package/src/commands/maintenance/status.ts +34 -0
  47. package/src/commands/media/export.ts +91 -0
  48. package/src/commands/media/fetch.ts +62 -0
  49. package/src/commands/media/import.ts +175 -0
  50. package/src/commands/pull.ts +210 -0
  51. package/src/commands/push.ts +108 -0
  52. package/src/commands/recycle-bin/empty.ts +157 -0
  53. package/src/commands/remote/add.ts +78 -0
  54. package/src/commands/remote/job.ts +53 -0
  55. package/src/commands/remote/list.ts +41 -0
  56. package/src/commands/remote/remove.ts +26 -0
  57. package/src/commands/schema.ts +32 -0
  58. package/src/commands/start.ts +64 -0
  59. package/src/commands/status.ts +103 -0
  60. package/src/commands/types/add-property.ts +116 -0
  61. package/src/commands/types/check.ts +88 -0
  62. package/src/commands/types/export.ts +32 -0
  63. package/src/commands/types/new.ts +116 -0
  64. package/src/commands/types/purge.ts +65 -0
  65. package/src/commands/types/rewrite.ts +30 -0
  66. package/src/commands/types/sync.ts +66 -0
  67. package/src/commands/upgrade/check.ts +69 -0
  68. package/src/commands/upgrade/index.ts +120 -0
  69. package/src/commands/upgrade/ledger.ts +55 -0
  70. package/src/commands/users/disable.ts +54 -0
  71. package/src/commands/users/enable.ts +54 -0
  72. package/src/commands/users/invite.ts +82 -0
  73. package/src/commands/users/list.ts +62 -0
  74. package/src/commands/users/reset-2fa.ts +48 -0
  75. package/src/commands/users/reset-password.ts +58 -0
  76. package/src/commands/users/unlock.ts +54 -0
  77. package/src/commands/whoami.ts +41 -0
  78. package/src/errors.ts +90 -0
  79. package/src/load.ts +59 -0
  80. package/src/main.ts +41 -0
  81. package/src/manifest.ts +30 -0
  82. package/src/plugin.ts +38 -0
  83. package/src/reference.ts +118 -0
  84. package/src/remote/client.ts +25 -11
  85. package/src/remote/remotes-file.ts +3 -2
  86. package/src/remote/transfer.ts +27 -11
  87. package/src/support/components.ts +7 -0
  88. package/src/support/describe.ts +179 -0
  89. package/src/support/domains.ts +89 -0
  90. package/src/support/flags.ts +157 -0
  91. package/src/support/media-tree.ts +72 -0
  92. package/src/support/people.ts +134 -0
  93. package/src/support/prompt.ts +126 -0
  94. package/src/support/publish.ts +142 -0
  95. package/src/support/remote.ts +57 -0
  96. package/src/support/serve.ts +460 -0
  97. package/src/support/site.ts +26 -0
  98. package/src/support/types.ts +112 -0
  99. package/src/support/umbraco.ts +86 -0
  100. package/templates/basic/bundle/content/bundle.json +1 -1
  101. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  102. package/templates/basic/bundle/package.json +1 -1
  103. package/templates/demo/harbourstone/bundle/package.json +1 -1
  104. package/templates/starter/bundle/package.json +1 -1
@@ -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,157 @@
1
+ import { ObjectTypes } from '@crombolo/core'
2
+ import {
3
+ appendCacheInstruction,
4
+ currentSchemaState,
5
+ DocumentRepository,
6
+ type NodeRow,
7
+ } from '@crombolo/data'
8
+ import { bootstrapDatabase, MediaFileStore, mediaFileOf, mediaStoreFor } from '@crombolo/server'
9
+ import { Flags } from '@oclif/core'
10
+ import { BaseCommand } from '../../base.ts'
11
+ import { dryRun } from '../../support/flags.ts'
12
+ import { confirm } from '../../support/prompt.ts'
13
+ import { siteConfig } from '../../support/site.ts'
14
+
15
+ /** Deletes for good what is in the recycle bin: pages, media and their files. */
16
+ export default class RecycleBinEmpty extends BaseCommand {
17
+ static override summary = 'delete what is in the recycle bin, for good'
18
+ static override description = [
19
+ 'Pages and media both, unless --content or --media names one. --older-than keeps what was trashed recently; an item trashed before crombolo recorded when is kept by it too, and counted.',
20
+ 'Asks first, unless --yes; --dry-run lists what would go.',
21
+ ].join('\n\n')
22
+ static override flags = {
23
+ content: Flags.boolean({ summary: 'only pages', exclusive: ['media'] }),
24
+ media: Flags.boolean({ summary: 'only media, with their files', exclusive: ['content'] }),
25
+ 'older-than': Flags.integer({
26
+ summary: 'only what was trashed more than this many days ago',
27
+ min: 0,
28
+ }),
29
+ yes: Flags.boolean({ summary: "don't ask" }),
30
+ 'dry-run': dryRun,
31
+ }
32
+ static override audit = {
33
+ category: 'content',
34
+ action: 'EmptyRecycleBin',
35
+ entityType: 'Document',
36
+ } as const
37
+ static override spec = {
38
+ effects: 'idempotent',
39
+ cardinality: 'bounded',
40
+ confirmationBypassArg: '--yes',
41
+ outputFields: [
42
+ {
43
+ name: 'items',
44
+ type: 'array',
45
+ items: {
46
+ type: 'object',
47
+ fields: [
48
+ { name: 'key', type: 'string' },
49
+ { name: 'name', type: 'string' },
50
+ { name: 'kind', type: 'string', enum: ['content', 'media'] },
51
+ { name: 'trashedAt', type: 'string', nullable: true },
52
+ ],
53
+ },
54
+ },
55
+ { name: 'kept', type: 'integer', description: 'trashed too recently, or at an unknown time' },
56
+ { name: 'changed', type: 'boolean' },
57
+ ],
58
+ errors: ['confirmation_required', 'refused'],
59
+ } as const
60
+
61
+ async run() {
62
+ const { flags } = await this.parse(RecycleBinEmpty)
63
+ const config = await siteConfig()
64
+ const { db } = await bootstrapDatabase(config)
65
+ try {
66
+ const kinds = (['content', 'media'] as const).filter(
67
+ (kind) => (!flags.content && !flags.media) || flags[kind],
68
+ )
69
+ const files = new MediaFileStore(await mediaStoreFor(config))
70
+ const state = await currentSchemaState(db)
71
+ const cutoff =
72
+ flags['older-than'] === undefined
73
+ ? undefined
74
+ : Date.now() - flags['older-than'] * 86_400_000
75
+ const chosen: Array<{
76
+ repo: DocumentRepository
77
+ node: NodeRow
78
+ kind: 'content' | 'media'
79
+ trashedAt: Date | undefined
80
+ }> = []
81
+ let kept = 0
82
+ for (const kind of kinds) {
83
+ const repo = new DocumentRepository(db, {
84
+ kind: kind === 'media' ? 'media' : 'document',
85
+ nodeState: { version: state.version, revision: state.revision },
86
+ nodeId: `cli:${config.nodeId}`,
87
+ })
88
+ const roots = await repo.trashedRoots()
89
+ const dates = await repo.trashedDates(roots.map((node) => node.id))
90
+ for (const node of roots) {
91
+ const trashedAt = dates.get(node.id)
92
+ if (cutoff !== undefined && (!trashedAt || trashedAt.getTime() > cutoff)) {
93
+ kept += 1
94
+ continue
95
+ }
96
+ chosen.push({ repo, node, kind, trashedAt })
97
+ }
98
+ }
99
+ const items = chosen.map(({ node, kind, trashedAt }) => ({
100
+ key: node.key,
101
+ name: node.text ?? node.key,
102
+ kind,
103
+ trashedAt: trashedAt?.toISOString() ?? null,
104
+ }))
105
+ for (const item of items)
106
+ this.say(
107
+ ` ${flags['dry-run'] ? 'would delete' : 'deletes'} ${item.kind.padEnd(7)} ${item.name}`,
108
+ )
109
+ if (kept > 0)
110
+ this.say(` keeps ${kept} item(s) trashed too recently, or before the date was recorded`)
111
+ if (items.length === 0) {
112
+ this.say(' The recycle bin has nothing to delete.')
113
+ this.changedNothing = true
114
+ return { items, kept, changed: false }
115
+ }
116
+ if (flags['dry-run']) {
117
+ this.changedNothing = true
118
+ return { items, kept, changed: false }
119
+ }
120
+ if (!flags.yes)
121
+ await confirm(
122
+ ` Delete ${items.length} item(s) and everything below them, for good? [y/N]`,
123
+ {
124
+ bypass: '--yes',
125
+ },
126
+ )
127
+ let mediaChanged = false
128
+ for (const { repo, node, kind } of chosen) {
129
+ // A media item's file goes with it, as emptying the bin in the back office does.
130
+ const paths: string[] = []
131
+ if (kind === 'media') {
132
+ const rows = await db.query<{ unique_id: string }>(
133
+ 'SELECT unique_id FROM node WHERE (id = ? OR path LIKE ?) AND node_object_type = ?',
134
+ [node.id, `${node.path},%`, ObjectTypes.Media],
135
+ )
136
+ for (const row of rows) {
137
+ const media = await repo.byKey(String(row.unique_id))
138
+ const file = media ? mediaFileOf(media) : null
139
+ if (file) paths.push(file)
140
+ }
141
+ }
142
+ if (await repo.delete(node.key)) for (const path of paths) await files.remove(path)
143
+ if (kind === 'media') mediaChanged = true
144
+ }
145
+ // One instruction for the run, as a node's own empty-bin does.
146
+ await appendCacheInstruction(db, {
147
+ kind: 'content',
148
+ payload: mediaChanged ? { media: true } : { recycleBin: true },
149
+ by: `cli:${config.nodeId}`,
150
+ })
151
+ this.say(` deleted ${items.length} item(s)`)
152
+ return { items, kept, changed: true }
153
+ } finally {
154
+ await db.close()
155
+ }
156
+ }
157
+ }
@@ -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
+ }