@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,38 @@
|
|
|
1
|
+
import { hostname } from 'node:os'
|
|
2
|
+
import { Args, Flags } from '@oclif/core'
|
|
3
|
+
import { BaseCommand } from '../base.ts'
|
|
4
|
+
import { signIn } from '../remote/client.ts'
|
|
5
|
+
import { connectRemote, keychain, openInBrowser } from '../support/remote.ts'
|
|
6
|
+
|
|
7
|
+
export default class Login extends BaseCommand {
|
|
8
|
+
static override summary = 'sign in to a remote with a code you approve in its back office'
|
|
9
|
+
static override description =
|
|
10
|
+
'The sign-in is kept in your keychain. It waits for the approval, printing where to give it; no terminal is needed.'
|
|
11
|
+
static override args = {
|
|
12
|
+
remote: Args.string({ description: 'the remote; the only one, when there is one' }),
|
|
13
|
+
}
|
|
14
|
+
static override flags = {
|
|
15
|
+
'no-browser': Flags.boolean({ summary: "don't open the approval page" }),
|
|
16
|
+
}
|
|
17
|
+
static override spec = {
|
|
18
|
+
effects: 'non_idempotent',
|
|
19
|
+
cardinality: 'single',
|
|
20
|
+
outputFields: [{ name: 'remote', type: 'string' }],
|
|
21
|
+
errors: ['not_found', 'auth', 'timeout', 'unavailable'],
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
async run() {
|
|
25
|
+
const { args, flags } = await this.parse(Login)
|
|
26
|
+
const { entry, discovery } = await connectRemote(args.remote, undefined)
|
|
27
|
+
await signIn(discovery, keychain(), {
|
|
28
|
+
clientName: `crombolo on ${hostname()}`,
|
|
29
|
+
onCode: (code, url) => {
|
|
30
|
+
this.progress(` Approve this sign-in at ${url}`)
|
|
31
|
+
this.progress(` and check it shows ${code}`)
|
|
32
|
+
openInBrowser(url, flags['no-browser'])
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
this.say(` signed in to ${entry.name}`)
|
|
36
|
+
return { remote: entry.name }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { forgetSignIn, readSignIn } from '@crombolo/server'
|
|
2
|
+
import { Args } from '@oclif/core'
|
|
3
|
+
import { BaseCommand } from '../base.ts'
|
|
4
|
+
import { connectRemote, keychain } from '../support/remote.ts'
|
|
5
|
+
|
|
6
|
+
export default class Logout extends BaseCommand {
|
|
7
|
+
static override summary = 'sign out of a remote, here and at the remote'
|
|
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: 'idempotent',
|
|
13
|
+
cardinality: 'single',
|
|
14
|
+
outputFields: [
|
|
15
|
+
{ name: 'remote', type: 'string' },
|
|
16
|
+
{ name: 'changed', type: 'boolean' },
|
|
17
|
+
],
|
|
18
|
+
errors: ['not_found', 'unavailable'],
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
async run() {
|
|
22
|
+
const { args } = await this.parse(Logout)
|
|
23
|
+
const { entry, discovery } = await connectRemote(args.remote, undefined)
|
|
24
|
+
const signedIn = await readSignIn(keychain(), discovery.api)
|
|
25
|
+
// Ended at the remote too, best effort: the device leaves "Signed-in devices".
|
|
26
|
+
if (signedIn)
|
|
27
|
+
await fetch(signedIn.tokenUrl.replace(/\/token$/, '/revoke'), {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
30
|
+
body: new URLSearchParams({ token: signedIn.accessToken }).toString(),
|
|
31
|
+
}).catch(() => undefined)
|
|
32
|
+
await forgetSignIn(keychain(), discovery.api)
|
|
33
|
+
this.say(` signed out of ${entry.name}`)
|
|
34
|
+
return { remote: entry.name, changed: signedIn !== undefined }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import * as ops from '../../operations/commands.ts'
|
|
4
|
+
import { siteConfig } from '../../support/site.ts'
|
|
5
|
+
|
|
6
|
+
/** The cluster-wide pause on editing that a restore or a swap needs: a database flag every node reads. */
|
|
7
|
+
export default class MaintenancePause extends BaseCommand {
|
|
8
|
+
static override summary = "refuse every editor's save, on every node, until resumed"
|
|
9
|
+
static override description = 'Readers are unaffected and no node drains.'
|
|
10
|
+
static override flags = {
|
|
11
|
+
reason: Flags.string({ summary: 'what editors are told', helpValue: '<text>' }),
|
|
12
|
+
}
|
|
13
|
+
static override audit = { category: 'system', action: 'PauseEditing' } as const
|
|
14
|
+
static override spec = {
|
|
15
|
+
effects: 'idempotent',
|
|
16
|
+
cardinality: 'single',
|
|
17
|
+
outputFields: [
|
|
18
|
+
{ name: 'paused', type: 'boolean' },
|
|
19
|
+
{ name: 'reason', type: 'string', nullable: true },
|
|
20
|
+
],
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
async run() {
|
|
24
|
+
const { flags } = await this.parse(MaintenancePause)
|
|
25
|
+
const paused = await ops.pauseEditing(await siteConfig(), { reason: flags.reason })
|
|
26
|
+
this.say(`Editing paused${paused.reason ? `: ${paused.reason}` : ''}.`)
|
|
27
|
+
return { paused: true, reason: paused.reason ?? null }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.ts'
|
|
2
|
+
import * as ops from '../../operations/commands.ts'
|
|
3
|
+
import { siteConfig } from '../../support/site.ts'
|
|
4
|
+
|
|
5
|
+
export default class MaintenanceResume extends BaseCommand {
|
|
6
|
+
static override summary = 'let editors save again'
|
|
7
|
+
static override audit = { category: 'system', action: 'ResumeEditing' } as const
|
|
8
|
+
static override spec = {
|
|
9
|
+
effects: 'idempotent',
|
|
10
|
+
cardinality: 'single',
|
|
11
|
+
outputFields: [{ name: 'paused', type: 'boolean' }],
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
async run() {
|
|
15
|
+
await this.parse(MaintenanceResume)
|
|
16
|
+
await ops.resumeEditing(await siteConfig())
|
|
17
|
+
this.say('Editing resumed.')
|
|
18
|
+
return { paused: false }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.ts'
|
|
2
|
+
import * as ops from '../../operations/commands.ts'
|
|
3
|
+
import { siteConfig } from '../../support/site.ts'
|
|
4
|
+
|
|
5
|
+
export default class MaintenanceStatus extends BaseCommand {
|
|
6
|
+
static override summary = 'whether editing is paused, by whom and why'
|
|
7
|
+
static override spec = {
|
|
8
|
+
effects: 'read_only',
|
|
9
|
+
cardinality: 'single',
|
|
10
|
+
outputFields: [
|
|
11
|
+
{
|
|
12
|
+
name: 'paused',
|
|
13
|
+
type: 'object',
|
|
14
|
+
nullable: true,
|
|
15
|
+
fields: [
|
|
16
|
+
{ name: 'at', type: 'string' },
|
|
17
|
+
{ name: 'by', type: 'string', nullable: true },
|
|
18
|
+
{ name: 'reason', type: 'string', nullable: true },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
async run() {
|
|
25
|
+
await this.parse(MaintenanceStatus)
|
|
26
|
+
const paused = await ops.editingPaused(await siteConfig())
|
|
27
|
+
this.say(
|
|
28
|
+
paused
|
|
29
|
+
? `Editing paused since ${paused.at}${paused.by ? ` by ${paused.by}` : ''}${paused.reason ? `: ${paused.reason}` : ''}.`
|
|
30
|
+
: 'Editing is not paused.',
|
|
31
|
+
)
|
|
32
|
+
return { paused: paused ?? null }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { extname, join, resolve } from 'node:path'
|
|
3
|
+
import type { NodeRow } from '@crombolo/data'
|
|
4
|
+
import { bootstrapDatabase, mediaFileOf, safeFileName } from '@crombolo/server'
|
|
5
|
+
import { Args, Flags } from '@oclif/core'
|
|
6
|
+
import { BaseCommand } from '../../base.ts'
|
|
7
|
+
import { here } from '../../support/describe.ts'
|
|
8
|
+
import { childrenByName, mediaParent, openMedia } from '../../support/media-tree.ts'
|
|
9
|
+
import { siteConfig } from '../../support/site.ts'
|
|
10
|
+
|
|
11
|
+
/** The media library, or a folder of it, back out as files in folders. */
|
|
12
|
+
export default class MediaExport extends BaseCommand {
|
|
13
|
+
static override summary = 'write media files back out as a folder tree'
|
|
14
|
+
static override examples = ['<%= config.bin %> media export /Gallery --out ./gallery']
|
|
15
|
+
static override args = {
|
|
16
|
+
folder: Args.string({
|
|
17
|
+
description: 'a media folder, as a path or uuid; everything when not given',
|
|
18
|
+
}),
|
|
19
|
+
}
|
|
20
|
+
static override flags = {
|
|
21
|
+
out: Flags.string({ summary: 'the folder to write into', helpValue: '<dir>', required: true }),
|
|
22
|
+
}
|
|
23
|
+
static override spec = {
|
|
24
|
+
effects: 'idempotent',
|
|
25
|
+
cardinality: 'bounded',
|
|
26
|
+
outputFields: [
|
|
27
|
+
{ name: 'out', type: 'string' },
|
|
28
|
+
{ name: 'files', type: 'integer' },
|
|
29
|
+
{ name: 'bytes', type: 'integer' },
|
|
30
|
+
{
|
|
31
|
+
name: 'missing',
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: { type: 'string' },
|
|
34
|
+
description: 'items whose file is not in the store',
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
errors: ['not_found'],
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
async run() {
|
|
41
|
+
const { args, flags } = await this.parse(MediaExport)
|
|
42
|
+
const config = await siteConfig()
|
|
43
|
+
const { db } = await bootstrapDatabase(config)
|
|
44
|
+
try {
|
|
45
|
+
const tree = await openMedia(db, config)
|
|
46
|
+
const start = await mediaParent(db, args.folder)
|
|
47
|
+
const out = resolve(flags.out)
|
|
48
|
+
let files = 0
|
|
49
|
+
let bytes = 0
|
|
50
|
+
const missing: string[] = []
|
|
51
|
+
|
|
52
|
+
const write = async (node: NodeRow, dir: string) => {
|
|
53
|
+
const media = await tree.repo.byKey(node.key)
|
|
54
|
+
const src = media ? mediaFileOf(media) : null
|
|
55
|
+
const name = node.text ?? node.key
|
|
56
|
+
if (!src) {
|
|
57
|
+
// A folder, or an item with nothing uploaded: its children follow.
|
|
58
|
+
const children = await childrenByName(tree, node)
|
|
59
|
+
if (children.size === 0 && media) return
|
|
60
|
+
const into = join(dir, safeFileName(name))
|
|
61
|
+
for (const child of children.values()) await write(child, into)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
const stored = await tree.files.open(src)
|
|
65
|
+
if (!stored) {
|
|
66
|
+
missing.push(name)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const extension = extname(src)
|
|
70
|
+
const fileName = safeFileName(
|
|
71
|
+
extname(name).toLowerCase() === extension.toLowerCase() ? name : `${name}${extension}`,
|
|
72
|
+
)
|
|
73
|
+
const content = await stored.bytes()
|
|
74
|
+
mkdirSync(dir, { recursive: true })
|
|
75
|
+
writeFileSync(join(dir, fileName), content)
|
|
76
|
+
files += 1
|
|
77
|
+
bytes += content.length
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (start) await write(start, out)
|
|
81
|
+
else for (const child of (await childrenByName(tree, null)).values()) await write(child, out)
|
|
82
|
+
mkdirSync(out, { recursive: true })
|
|
83
|
+
this.say(` wrote ${files} file(s) to ${here(out)}`)
|
|
84
|
+
for (const name of missing)
|
|
85
|
+
this.warning(` missing ${name}: its file is not in the media store`)
|
|
86
|
+
return { out: here(out), files, bytes, missing }
|
|
87
|
+
} finally {
|
|
88
|
+
await db.close()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -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,175 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { extname, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { appendCacheInstruction, type NodeRow } from '@crombolo/data'
|
|
4
|
+
import { bootstrapDatabase, mediaFileOf } from '@crombolo/server'
|
|
5
|
+
import { Args, Flags } from '@oclif/core'
|
|
6
|
+
import { BaseCommand } from '../../base.ts'
|
|
7
|
+
import { dryRun } from '../../support/flags.ts'
|
|
8
|
+
import { childrenByName, mediaParent, openMedia, sha256 } from '../../support/media-tree.ts'
|
|
9
|
+
import { siteConfig } from '../../support/site.ts'
|
|
10
|
+
|
|
11
|
+
interface Outcome {
|
|
12
|
+
path: string
|
|
13
|
+
action: 'created' | 'folder' | 'skipped' | 'differs' | 'unsupported'
|
|
14
|
+
type?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A folder of files into the media library: a media folder for each directory,
|
|
19
|
+
* an item for each file, typed by its extension as an upload is. A file already
|
|
20
|
+
* there with the same bytes is skipped, so an import that stopped part-way runs
|
|
21
|
+
* again from where it got to.
|
|
22
|
+
*/
|
|
23
|
+
export default class MediaImport extends BaseCommand {
|
|
24
|
+
static override summary = 'import a folder of files into the media library, keeping its folders'
|
|
25
|
+
static override description = [
|
|
26
|
+
'Each directory becomes a media folder and each file a media item, typed by its extension as an upload is. Files no media type takes are reported and left.',
|
|
27
|
+
'Run it again after a stop: a file already imported with the same bytes is skipped, and one whose bytes differ is reported and left alone.',
|
|
28
|
+
].join('\n\n')
|
|
29
|
+
static override examples = ['<%= config.bin %> media import ./photos --under /Gallery --dry-run']
|
|
30
|
+
static override args = {
|
|
31
|
+
dir: Args.string({ required: true, description: 'the folder to import' }),
|
|
32
|
+
}
|
|
33
|
+
static override flags = {
|
|
34
|
+
under: Flags.string({
|
|
35
|
+
summary: 'the media folder to import into; the root when not given',
|
|
36
|
+
helpValue: '<path|uuid>',
|
|
37
|
+
}),
|
|
38
|
+
'dry-run': dryRun,
|
|
39
|
+
}
|
|
40
|
+
static override audit = { category: 'media', action: 'Import', entityType: 'Media' } as const
|
|
41
|
+
static override spec = {
|
|
42
|
+
effects: 'idempotent',
|
|
43
|
+
cardinality: 'bounded',
|
|
44
|
+
outputFields: [
|
|
45
|
+
{ name: 'created', type: 'integer' },
|
|
46
|
+
{ name: 'folders', type: 'integer' },
|
|
47
|
+
{ name: 'skipped', type: 'integer' },
|
|
48
|
+
{ name: 'differs', type: 'array', items: { type: 'string' } },
|
|
49
|
+
{ name: 'unsupported', type: 'array', items: { type: 'string' } },
|
|
50
|
+
{ name: 'changed', type: 'boolean' },
|
|
51
|
+
],
|
|
52
|
+
errors: ['not_found', 'io'],
|
|
53
|
+
} as const
|
|
54
|
+
|
|
55
|
+
async run() {
|
|
56
|
+
const { args, flags } = await this.parse(MediaImport)
|
|
57
|
+
const root = resolve(args.dir)
|
|
58
|
+
if (!statSync(root, { throwIfNoEntry: false })?.isDirectory())
|
|
59
|
+
this.fail('not_found', `${args.dir} is not a folder.`)
|
|
60
|
+
const config = await siteConfig()
|
|
61
|
+
const { db } = await bootstrapDatabase(config)
|
|
62
|
+
try {
|
|
63
|
+
const tree = await openMedia(db, config)
|
|
64
|
+
const parent = await mediaParent(db, flags.under)
|
|
65
|
+
const folderTypes = await tree.port.folderTypes()
|
|
66
|
+
const folderType = folderTypes.find((t) => t.name === 'Folder') ?? folderTypes[0]
|
|
67
|
+
if (!folderType) this.fail('refused', 'This site has no media folder type to import into.')
|
|
68
|
+
const typeFor = new Map<string, { key: string; name: string } | undefined>()
|
|
69
|
+
const outcomes: Outcome[] = []
|
|
70
|
+
const dry = flags['dry-run']
|
|
71
|
+
|
|
72
|
+
const walk = async (dir: string, into: NodeRow | null, intoKey: string | null) => {
|
|
73
|
+
const existing =
|
|
74
|
+
into || !dry ? await childrenByName(tree, into) : new Map<string, NodeRow>()
|
|
75
|
+
for (const name of readdirSync(dir).sort()) {
|
|
76
|
+
if (name.startsWith('.')) continue
|
|
77
|
+
const path = join(dir, name)
|
|
78
|
+
const shown = relative(root, path)
|
|
79
|
+
if (statSync(path).isDirectory()) {
|
|
80
|
+
let folder = existing.get(name) ?? null
|
|
81
|
+
if (!folder) {
|
|
82
|
+
outcomes.push({ path: shown, action: 'folder' })
|
|
83
|
+
if (!dry) {
|
|
84
|
+
const key = crypto.randomUUID()
|
|
85
|
+
await tree.repo.create({
|
|
86
|
+
key,
|
|
87
|
+
contentTypeKey: folderType.key,
|
|
88
|
+
componentKey: null,
|
|
89
|
+
parentKey: intoKey,
|
|
90
|
+
values: [],
|
|
91
|
+
variants: [{ culture: null, segment: null, name }],
|
|
92
|
+
})
|
|
93
|
+
folder = (await tree.nodes.byKey(key)) ?? null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await walk(path, folder, folder?.key ?? null)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const extension = extname(name).slice(1).toLowerCase()
|
|
100
|
+
if (!typeFor.has(extension)) {
|
|
101
|
+
const types = await tree.port.typesForExtension(extension)
|
|
102
|
+
typeFor.set(extension, types.find((t) => t.matched) ?? types[0])
|
|
103
|
+
}
|
|
104
|
+
const type = typeFor.get(extension)
|
|
105
|
+
if (!type) {
|
|
106
|
+
outcomes.push({ path: shown, action: 'unsupported' })
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const bytes = await Bun.file(path).bytes()
|
|
110
|
+
const there = existing.get(name)
|
|
111
|
+
if (there) {
|
|
112
|
+
const media = await tree.repo.byKey(there.key)
|
|
113
|
+
const src = media ? mediaFileOf(media) : null
|
|
114
|
+
const stored = src ? await tree.files.open(src) : undefined
|
|
115
|
+
const same = stored ? sha256(await stored.bytes()) === sha256(bytes) : false
|
|
116
|
+
outcomes.push({ path: shown, action: same ? 'skipped' : 'differs', type: type.name })
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
outcomes.push({ path: shown, action: 'created', type: type.name })
|
|
120
|
+
if (dry) continue
|
|
121
|
+
const upload = crypto.randomUUID()
|
|
122
|
+
await tree.files.saveTemporary(upload, new File([bytes], name))
|
|
123
|
+
await tree.repo.create({
|
|
124
|
+
key: crypto.randomUUID(),
|
|
125
|
+
contentTypeKey: type.key,
|
|
126
|
+
componentKey: null,
|
|
127
|
+
parentKey: intoKey,
|
|
128
|
+
values: [
|
|
129
|
+
{
|
|
130
|
+
alias: 'umbracoFile',
|
|
131
|
+
culture: null,
|
|
132
|
+
segment: null,
|
|
133
|
+
value: { temporaryFileId: upload },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
variants: [{ culture: null, segment: null, name }],
|
|
137
|
+
})
|
|
138
|
+
const made = outcomes.filter((o) => o.action === 'created').length
|
|
139
|
+
if (made % 25 === 0) this.progress(` imported ${made}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
await walk(root, parent, parent?.key ?? null)
|
|
143
|
+
|
|
144
|
+
const count = (action: Outcome['action']) => outcomes.filter((o) => o.action === action)
|
|
145
|
+
const verb = dry ? 'would create' : 'created'
|
|
146
|
+
for (const o of outcomes.filter((o) => o.action !== 'skipped'))
|
|
147
|
+
this.say(
|
|
148
|
+
` ${(o.action === 'created' ? verb : o.action === 'folder' ? `${verb} folder` : o.action).padEnd(20)} ${o.path}${o.type ? ` (${o.type})` : ''}`,
|
|
149
|
+
)
|
|
150
|
+
this.say(
|
|
151
|
+
`\n ${count('created').length} file(s) and ${count('folder').length} folder(s) ${dry ? 'to create' : 'created'}, ${count('skipped').length} already here` +
|
|
152
|
+
`${count('differs').length > 0 ? `, ${count('differs').length} differ` : ''}${count('unsupported').length > 0 ? `, ${count('unsupported').length} no media type takes` : ''}`,
|
|
153
|
+
)
|
|
154
|
+
const changed = !dry && (count('created').length > 0 || count('folder').length > 0)
|
|
155
|
+
if (dry) this.changedNothing = true
|
|
156
|
+
// Once for the run: the nodes refresh what shows media.
|
|
157
|
+
if (changed)
|
|
158
|
+
await appendCacheInstruction(db, {
|
|
159
|
+
kind: 'content',
|
|
160
|
+
payload: { media: true },
|
|
161
|
+
by: `cli:${config.nodeId}`,
|
|
162
|
+
})
|
|
163
|
+
return {
|
|
164
|
+
created: count('created').length,
|
|
165
|
+
folders: count('folder').length,
|
|
166
|
+
skipped: count('skipped').length,
|
|
167
|
+
differs: count('differs').map((o) => o.path),
|
|
168
|
+
unsupported: count('unsupported').map((o) => o.path),
|
|
169
|
+
changed,
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
await db.close()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|