@meith/cli 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/LICENSE.md +165 -0
- package/bin/community.mjs +117 -0
- package/package.json +47 -0
- package/src/args.ts +63 -0
- package/src/backup.ts +677 -0
- package/src/board-eject.ts +180 -0
- package/src/commands.ts +269 -0
- package/src/context.ts +77 -0
- package/src/demo.ts +61 -0
- package/src/import-files.ts +189 -0
- package/src/import.ts +142 -0
- package/src/index.ts +394 -0
- package/src/plugin-manifest.ts +176 -0
- package/src/plugins.ts +65 -0
- package/src/profile-fields.ts +107 -0
- package/src/push.ts +47 -0
- package/src/redaction.ts +23 -0
- package/src/search.ts +33 -0
- package/src/tasks.ts +69 -0
- package/src/upgrade.ts +125 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { getDb, PostgresProfileFieldRepository } from '@meith/db'
|
|
3
|
+
import { FIELD_TYPES, ProfileFieldService } from '@meith/profile-fields'
|
|
4
|
+
|
|
5
|
+
import { type Flags, optional, parseFlags, required } from './args'
|
|
6
|
+
import { requirePostgres } from './context'
|
|
7
|
+
|
|
8
|
+
function service(): ProfileFieldService {
|
|
9
|
+
requirePostgres()
|
|
10
|
+
return new ProfileFieldService({ fields: new PostgresProfileFieldRepository(getDb()) })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function flag(flags: Flags, name: string): boolean {
|
|
14
|
+
const raw = optional(flags, name)
|
|
15
|
+
if (raw === undefined) return false
|
|
16
|
+
const value = raw.toLowerCase()
|
|
17
|
+
if (['1', 'true', 'yes', 'on'].includes(value)) return true
|
|
18
|
+
if (['0', 'false', 'no', 'off'].includes(value)) return false
|
|
19
|
+
throw new ValidationError(`--${name} must be true or false, got "${raw}".`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function profileFieldList(): Promise<number> {
|
|
23
|
+
const fields = await service().listAll()
|
|
24
|
+
|
|
25
|
+
if (fields.length === 0) {
|
|
26
|
+
console.log(
|
|
27
|
+
'No custom profile fields.\n' +
|
|
28
|
+
'Add one: community profile-field:add --key pronouns --label Pronouns --type text',
|
|
29
|
+
)
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const width = Math.max(...fields.map((f) => f.key.length))
|
|
34
|
+
console.log(`${fields.length} custom profile field(s):\n`)
|
|
35
|
+
|
|
36
|
+
for (const field of fields) {
|
|
37
|
+
const marks = [
|
|
38
|
+
field.isActive ? null : 'inactive',
|
|
39
|
+
field.requiredAtRegistration ? 'required at registration' : null,
|
|
40
|
+
field.showInPostbit ? 'shown in postbit' : null,
|
|
41
|
+
field.defaultVisible ? null : 'hidden by default',
|
|
42
|
+
field.defaultEditable ? null : 'not member-editable',
|
|
43
|
+
].filter((mark): mark is string => mark !== null)
|
|
44
|
+
|
|
45
|
+
console.log(
|
|
46
|
+
` ${field.key.padEnd(width)} ${field.type ?? 'unknown type'}` +
|
|
47
|
+
` "${field.label}"` +
|
|
48
|
+
(marks.length === 0 ? '' : ` [${marks.join(', ')}]`),
|
|
49
|
+
)
|
|
50
|
+
if (field.options.length > 0) {
|
|
51
|
+
console.log(` ${' '.repeat(width)} options: ${field.options.join(', ')}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function profileFieldAdd(args: readonly string[]): Promise<number> {
|
|
58
|
+
const { flags } = parseFlags(args)
|
|
59
|
+
|
|
60
|
+
const type = required(flags, 'type')
|
|
61
|
+
if (!(FIELD_TYPES as readonly string[]).includes(type)) {
|
|
62
|
+
throw new ValidationError(`--type must be one of ${FIELD_TYPES.join(', ')}, got "${type}".`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rawOptions = optional(flags, 'options')
|
|
66
|
+
const options =
|
|
67
|
+
rawOptions === undefined
|
|
68
|
+
? []
|
|
69
|
+
: rawOptions
|
|
70
|
+
.split(',')
|
|
71
|
+
.map((option) => option.trim())
|
|
72
|
+
.filter((option) => option !== '')
|
|
73
|
+
|
|
74
|
+
const field = await service().create({
|
|
75
|
+
key: required(flags, 'key'),
|
|
76
|
+
label: required(flags, 'label'),
|
|
77
|
+
type,
|
|
78
|
+
options,
|
|
79
|
+
requiredAtRegistration: flag(flags, 'required'),
|
|
80
|
+
showInPostbit: flag(flags, 'postbit'),
|
|
81
|
+
displayOrder: Number(optional(flags, 'order') ?? 0),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
console.log(`Created profile field "${field.key}" (id ${field.id}, ${field.type}).`)
|
|
85
|
+
console.log('Every group can see and edit it. Per-group overrides arrive with the ACP screen.')
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function profileFieldRemove(args: readonly string[]): Promise<number> {
|
|
90
|
+
const { positional } = parseFlags(args)
|
|
91
|
+
const key = positional[0]
|
|
92
|
+
if (key === undefined) {
|
|
93
|
+
throw new ValidationError('Usage: community profile-field:remove <key>')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const removed = await service().remove(key)
|
|
97
|
+
if (!removed) {
|
|
98
|
+
console.error(`No such profile field: ${key}`)
|
|
99
|
+
return 1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(
|
|
103
|
+
`Removed profile field "${key}" and every member's answer to it.\n` +
|
|
104
|
+
'To keep the answers and only stop showing the field, set profile_fields.is_active = false instead.',
|
|
105
|
+
)
|
|
106
|
+
return 0
|
|
107
|
+
}
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { generateVapidKeys } from '@meith/notifications'
|
|
2
|
+
import { SettingsSnapshot, saveSettings } from '@meith/settings'
|
|
3
|
+
|
|
4
|
+
import { parseFlags } from './args'
|
|
5
|
+
import { createContext } from './context'
|
|
6
|
+
|
|
7
|
+
export async function pushKeys(args: readonly string[]): Promise<number> {
|
|
8
|
+
const { flags } = parseFlags(args)
|
|
9
|
+
const keys = await generateVapidKeys()
|
|
10
|
+
|
|
11
|
+
if (flags.get('save') !== 'true') {
|
|
12
|
+
console.log('push.vapid_public_key ' + keys.publicKey)
|
|
13
|
+
console.log('push.vapid_private_key ' + keys.privateKey)
|
|
14
|
+
console.log(
|
|
15
|
+
'\nPaste these into Admin → Settings → Push, or run this again with --save. ' +
|
|
16
|
+
'Replacing a key that is already in use invalidates every subscription ' +
|
|
17
|
+
'stored against it.',
|
|
18
|
+
)
|
|
19
|
+
return 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ctx = await createContext()
|
|
23
|
+
const snapshot = SettingsSnapshot.fromOverrides(await ctx.settings.loadAll())
|
|
24
|
+
|
|
25
|
+
const result = await saveSettings(
|
|
26
|
+
ctx.settings,
|
|
27
|
+
{
|
|
28
|
+
'push.vapid_public_key': keys.publicKey,
|
|
29
|
+
'push.vapid_private_key': keys.privateKey,
|
|
30
|
+
},
|
|
31
|
+
snapshot,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
console.log(
|
|
35
|
+
result.changed.length === 0
|
|
36
|
+
? 'The keys already on this board were not replaced.'
|
|
37
|
+
: 'Saved a new VAPID key pair. Every subscription stored against the old ' +
|
|
38
|
+
'pair is now dead, and each browser will resubscribe on its next visit ' +
|
|
39
|
+
'to the notification preferences screen.',
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if (!snapshot.get('push.enabled')) {
|
|
43
|
+
console.log('push.enabled is off — turn it on to offer web push to members.')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return 0
|
|
47
|
+
}
|
package/src/redaction.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
|
|
2
|
+
'AUTH_SECRET',
|
|
3
|
+
'TICK_SECRET',
|
|
4
|
+
'METRICS_TOKEN',
|
|
5
|
+
'DATABASE_URL',
|
|
6
|
+
'DIRECT_DATABASE_URL',
|
|
7
|
+
'MAIL_HTTP_TOKEN',
|
|
8
|
+
'MAIL_SMTP_PASSWORD',
|
|
9
|
+
'REDIS_URL',
|
|
10
|
+
'S3_SECRET_ACCESS_KEY',
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
export const NOT_SECRET_DESPITE_THE_NAME: ReadonlySet<string> = new Set([
|
|
14
|
+
'MAIL_HTTP_ENDPOINT',
|
|
15
|
+
'S3_ENDPOINT',
|
|
16
|
+
'APP_URL',
|
|
17
|
+
'S3_ACCESS_KEY_ID',
|
|
18
|
+
'MAIL_SMTP_USERNAME',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
export function looksLikeCredential(name: string): boolean {
|
|
22
|
+
return /SECRET|PASSWORD|TOKEN|_KEY|URL$/.test(name)
|
|
23
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getDb, PostgresSearchRepository } from '@meith/db'
|
|
2
|
+
|
|
3
|
+
import { requirePostgres } from './context'
|
|
4
|
+
|
|
5
|
+
const BATCH = 5_000
|
|
6
|
+
|
|
7
|
+
export async function searchReindex(): Promise<number> {
|
|
8
|
+
requirePostgres()
|
|
9
|
+
const search = new PostgresSearchRepository(getDb())
|
|
10
|
+
|
|
11
|
+
const before = await search.indexProgress()
|
|
12
|
+
if (before.pending === 0) {
|
|
13
|
+
console.log(`Nothing to do: all ${before.indexed} post(s) are indexed.`)
|
|
14
|
+
return 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(`Indexing ${before.pending} post(s)…`)
|
|
18
|
+
|
|
19
|
+
let cursor = 0
|
|
20
|
+
let indexed = 0
|
|
21
|
+
for (;;) {
|
|
22
|
+
const chunk = await search.reindexChunk(cursor, BATCH)
|
|
23
|
+
indexed += chunk.indexed
|
|
24
|
+
if (chunk.nextCursor === null) break
|
|
25
|
+
cursor = chunk.nextCursor
|
|
26
|
+
console.log(` ${indexed}…`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const after = await search.indexProgress()
|
|
30
|
+
|
|
31
|
+
console.log(`Indexed ${indexed} post(s). ${after.indexed} indexed, ${after.pending} pending.`)
|
|
32
|
+
return after.pending === 0 ? 0 : 1
|
|
33
|
+
}
|
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { drivers } from '@meith/drivers'
|
|
2
|
+
import { imageProcessor } from '@meith/drivers/images'
|
|
3
|
+
import { buildSchedulerBundle, type SchedulerBundle } from '@meith/runtime'
|
|
4
|
+
import { tick } from '@meith/tasks'
|
|
5
|
+
|
|
6
|
+
import { requirePostgres } from './context'
|
|
7
|
+
|
|
8
|
+
function scheduler(): SchedulerBundle {
|
|
9
|
+
requirePostgres()
|
|
10
|
+
return buildSchedulerBundle({
|
|
11
|
+
queue: drivers().queue,
|
|
12
|
+
mail: drivers().mail,
|
|
13
|
+
files: drivers().files,
|
|
14
|
+
images: imageProcessor,
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function taskList(): Promise<number> {
|
|
19
|
+
const { tasks } = scheduler()
|
|
20
|
+
const width = Math.max(...tasks.map((t) => t.id.length))
|
|
21
|
+
console.log(`${tasks.length} registered task(s):\n`)
|
|
22
|
+
for (const task of tasks) {
|
|
23
|
+
console.log(` ${task.id.padEnd(width)} every ${formatInterval(task.intervalSeconds)}`)
|
|
24
|
+
console.log(` ${' '.repeat(width)} ${task.title}`)
|
|
25
|
+
}
|
|
26
|
+
return 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function taskRun(args: readonly string[]): Promise<number> {
|
|
30
|
+
const only = args[0]
|
|
31
|
+
const { repository, tasks, onTaskFailure } = scheduler()
|
|
32
|
+
const selected = only === undefined ? tasks : tasks.filter((t) => t.id === only)
|
|
33
|
+
if (only !== undefined && selected.length === 0) {
|
|
34
|
+
console.error(
|
|
35
|
+
`No such task: ${only}\n` + `Run \`community task:list\` to see what is registered.`,
|
|
36
|
+
)
|
|
37
|
+
return 1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const outcomes = await tick({ repository, tasks: selected, onError: onTaskFailure })
|
|
41
|
+
|
|
42
|
+
const ran = outcomes.filter((o) => o.status === 'ran')
|
|
43
|
+
const failed = outcomes.filter((o) => o.status === 'failed')
|
|
44
|
+
const skipped = outcomes.filter((o) => o.status === 'skipped')
|
|
45
|
+
|
|
46
|
+
for (const outcome of [...ran, ...failed]) {
|
|
47
|
+
const detail = outcome.detail === undefined ? '' : ` ${JSON.stringify(outcome.detail)}`
|
|
48
|
+
console.log(
|
|
49
|
+
`${outcome.status === 'ran' ? 'ran ' : 'FAILED '} ${outcome.taskId}` +
|
|
50
|
+
` (${outcome.durationMs}ms)${detail}` +
|
|
51
|
+
(outcome.error === undefined ? '' : `\n ${outcome.error}`),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
if (skipped.length > 0) {
|
|
55
|
+
console.log(
|
|
56
|
+
`skipped ${skipped.length} not due or already claimed: ` +
|
|
57
|
+
skipped.map((o) => o.taskId).join(', '),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
if (outcomes.length === 0) console.log('Nothing registered to run.')
|
|
61
|
+
|
|
62
|
+
return failed.length > 0 ? 1 : 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatInterval(seconds: number): string {
|
|
66
|
+
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
|
67
|
+
if (seconds % 60 === 0) return `${seconds / 60}m`
|
|
68
|
+
return `${seconds}s`
|
|
69
|
+
}
|
package/src/upgrade.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appliedPluginMigrations,
|
|
3
|
+
applyPluginMigration,
|
|
4
|
+
getDb,
|
|
5
|
+
PostgresNavigationRepository,
|
|
6
|
+
readVersion,
|
|
7
|
+
recordVersion,
|
|
8
|
+
runMigrations,
|
|
9
|
+
} from '@meith/db'
|
|
10
|
+
import { type PluginDefinition, pluginNavigationPlacements } from '@meith/plugin-kit'
|
|
11
|
+
import { runPluginLifecycle } from '@meith/runtime'
|
|
12
|
+
import { type PluginUpgrade, planUpgrade, upgradeNotice } from '@meith/upgrade'
|
|
13
|
+
|
|
14
|
+
export const CODE_VERSION = '0.16.0'
|
|
15
|
+
|
|
16
|
+
export function pluginUpgrades(plugins: readonly PluginDefinition[]): readonly PluginUpgrade[] {
|
|
17
|
+
return plugins.map((plugin) => ({
|
|
18
|
+
key: plugin.key,
|
|
19
|
+
version: plugin.version,
|
|
20
|
+
dependsOn: plugin.dependsOn ?? [],
|
|
21
|
+
migrationIds: (plugin.migrations ?? []).map((migration) => migration.id),
|
|
22
|
+
}))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UpgradeOptions {
|
|
26
|
+
readonly dryRun: boolean
|
|
27
|
+
readonly plugins: readonly PluginDefinition[]
|
|
28
|
+
readonly log: (line: string) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function upgrade(options: UpgradeOptions): Promise<number> {
|
|
32
|
+
const db = getDb()
|
|
33
|
+
const plugins = pluginUpgrades(options.plugins)
|
|
34
|
+
|
|
35
|
+
const recordedVersion = (await readVersion(db, 'core')) ?? CODE_VERSION
|
|
36
|
+
|
|
37
|
+
const fresh: string[] = []
|
|
38
|
+
for (const plugin of options.plugins) {
|
|
39
|
+
if ((await readVersion(db, `plugin:${plugin.key}`)) === null) fresh.push(plugin.key)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const applied: Record<string, readonly string[]> = {}
|
|
43
|
+
for (const plugin of plugins) {
|
|
44
|
+
applied[plugin.key] = await appliedPluginMigrations(db, plugin.key)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const state = {
|
|
48
|
+
recordedVersion,
|
|
49
|
+
codeVersion: CODE_VERSION,
|
|
50
|
+
pendingCoreMigrations: [] as readonly string[],
|
|
51
|
+
plugins,
|
|
52
|
+
appliedPluginMigrations: applied,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const plan = planUpgrade(state)
|
|
56
|
+
|
|
57
|
+
if (plan.refusal !== null || plan.orderFailure !== null) {
|
|
58
|
+
options.log(upgradeNotice(plan, state) ?? 'This board cannot be upgraded.')
|
|
59
|
+
return 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
options.log(options.dryRun ? 'Plan:' : 'Upgrading…')
|
|
63
|
+
options.log(' 1. apply pending core migrations')
|
|
64
|
+
|
|
65
|
+
let stepNumber = 2
|
|
66
|
+
for (const plugin of plugins) {
|
|
67
|
+
const pending = plugin.migrationIds.filter((id) => !(applied[plugin.key] ?? []).includes(id))
|
|
68
|
+
if (pending.length === 0) continue
|
|
69
|
+
options.log(` ${stepNumber++}. ${plugin.key}: ${pending.join(', ')}`)
|
|
70
|
+
}
|
|
71
|
+
for (const key of fresh) {
|
|
72
|
+
if (options.plugins.find((plugin) => plugin.key === key)?.onInstall === undefined) continue
|
|
73
|
+
options.log(` ${stepNumber++}. ${key}: onInstall`)
|
|
74
|
+
}
|
|
75
|
+
options.log(` ${stepNumber++}. reconcile plugin navigation`)
|
|
76
|
+
options.log(` ${stepNumber}. record version ${CODE_VERSION}`)
|
|
77
|
+
|
|
78
|
+
if (options.dryRun) {
|
|
79
|
+
options.log('')
|
|
80
|
+
options.log('Nothing was changed. Run without --dry-run to apply.')
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const count = await runMigrations()
|
|
85
|
+
options.log(count === 0 ? 'Core: already up to date.' : `Core: applied ${count} migration(s).`)
|
|
86
|
+
|
|
87
|
+
for (const plugin of plugins) {
|
|
88
|
+
const definition = options.plugins.find((entry) => entry.key === plugin.key)
|
|
89
|
+
for (const migration of definition?.migrations ?? []) {
|
|
90
|
+
const ran = await applyPluginMigration(db, plugin.key, migration.id, migration.statements)
|
|
91
|
+
if (ran) options.log(`${plugin.key}: applied ${migration.id}.`)
|
|
92
|
+
}
|
|
93
|
+
/*
|
|
94
|
+
* onInstall runs after this plugin's migrations, so its tables exist, and
|
|
95
|
+
* before the version row that will stop it running again. A throw here
|
|
96
|
+
* stops the upgrade: a plugin that could not finish installing is one the
|
|
97
|
+
* board should not start serving.
|
|
98
|
+
*/
|
|
99
|
+
if (fresh.includes(plugin.key) && definition !== undefined) {
|
|
100
|
+
const { ran } = await runPluginLifecycle({ db, plugin: definition, phase: 'install' })
|
|
101
|
+
if (ran) options.log(`${plugin.key}: onInstall.`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await recordVersion(db, `plugin:${plugin.key}`, plugin.version)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const navigation = await new PostgresNavigationRepository(db).syncPluginItems(
|
|
108
|
+
pluginNavigationPlacements(options.plugins).map((item) => ({
|
|
109
|
+
key: item.key,
|
|
110
|
+
href: item.href,
|
|
111
|
+
audience: item.audience,
|
|
112
|
+
parentKey: item.parentKey,
|
|
113
|
+
})),
|
|
114
|
+
)
|
|
115
|
+
if (navigation.added.length > 0) {
|
|
116
|
+
options.log(`Navigation: added ${navigation.added.join(', ')}.`)
|
|
117
|
+
}
|
|
118
|
+
if (navigation.removed.length > 0) {
|
|
119
|
+
options.log(`Navigation: removed ${navigation.removed.join(', ')}.`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await recordVersion(db, 'core', CODE_VERSION)
|
|
123
|
+
options.log(`Recorded version ${CODE_VERSION}.`)
|
|
124
|
+
return 0
|
|
125
|
+
}
|