@meith/web 0.17.2 → 0.18.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 +21 -165
- package/app/admin/groups/promotions/page.tsx +6 -0
- package/app/api/system/tick/route.ts +27 -10
- package/bin/forum-web.mjs +232 -11
- package/bin/forum-web.test.ts +164 -1
- package/next.config.mjs +16 -10
- package/package.json +48 -48
- package/src/server/group-admin.ts +2 -6
- package/src/server/install.ts +2 -1
- package/src/server/upgrade-notice.ts +1 -1
package/LICENSE.md
CHANGED
|
@@ -1,165 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
An "Application" is any work that makes use of an interface provided
|
|
23
|
-
by the Library, but which is not otherwise based on the Library.
|
|
24
|
-
Defining a subclass of a class defined by the Library is deemed a mode
|
|
25
|
-
of using an interface provided by the Library.
|
|
26
|
-
|
|
27
|
-
A "Combined Work" is a work produced by combining or linking an
|
|
28
|
-
Application with the Library. The particular version of the Library
|
|
29
|
-
with which the Combined Work was made is also called the "Linked
|
|
30
|
-
Version".
|
|
31
|
-
|
|
32
|
-
The "Minimal Corresponding Source" for a Combined Work means the
|
|
33
|
-
Corresponding Source for the Combined Work, excluding any source code
|
|
34
|
-
for portions of the Combined Work that, considered in isolation, are
|
|
35
|
-
based on the Application, and not on the Linked Version.
|
|
36
|
-
|
|
37
|
-
The "Corresponding Application Code" for a Combined Work means the
|
|
38
|
-
object code and/or source code for the Application, including any data
|
|
39
|
-
and utility programs needed for reproducing the Combined Work from the
|
|
40
|
-
Application, but excluding the System Libraries of the Combined Work.
|
|
41
|
-
|
|
42
|
-
1. Exception to Section 3 of the GNU GPL.
|
|
43
|
-
|
|
44
|
-
You may convey a covered work under sections 3 and 4 of this License
|
|
45
|
-
without being bound by section 3 of the GNU GPL.
|
|
46
|
-
|
|
47
|
-
2. Conveying Modified Versions.
|
|
48
|
-
|
|
49
|
-
If you modify a copy of the Library, and, in your modifications, a
|
|
50
|
-
facility refers to a function or data to be supplied by an Application
|
|
51
|
-
that uses the facility (other than as an argument passed when the
|
|
52
|
-
facility is invoked), then you may convey a copy of the modified
|
|
53
|
-
version:
|
|
54
|
-
|
|
55
|
-
a) under this License, provided that you make a good faith effort to
|
|
56
|
-
ensure that, in the event an Application does not supply the
|
|
57
|
-
function or data, the facility still operates, and performs
|
|
58
|
-
whatever part of its purpose remains meaningful, or
|
|
59
|
-
|
|
60
|
-
b) under the GNU GPL, with none of the additional permissions of
|
|
61
|
-
this License applicable to that copy.
|
|
62
|
-
|
|
63
|
-
3. Object Code Incorporating Material from Library Header Files.
|
|
64
|
-
|
|
65
|
-
The object code form of an Application may incorporate material from
|
|
66
|
-
a header file that is part of the Library. You may convey such object
|
|
67
|
-
code under terms of your choice, provided that, if the incorporated
|
|
68
|
-
material is not limited to numerical parameters, data structure
|
|
69
|
-
layouts and accessors, or small macros, inline functions and templates
|
|
70
|
-
(ten or fewer lines in length), you do both of the following:
|
|
71
|
-
|
|
72
|
-
a) Give prominent notice with each copy of the object code that the
|
|
73
|
-
Library is used in it and that the Library and its use are
|
|
74
|
-
covered by this License.
|
|
75
|
-
|
|
76
|
-
b) Accompany the object code with a copy of the GNU GPL and this license
|
|
77
|
-
document.
|
|
78
|
-
|
|
79
|
-
4. Combined Works.
|
|
80
|
-
|
|
81
|
-
You may convey a Combined Work under terms of your choice that,
|
|
82
|
-
taken together, effectively do not restrict modification of the
|
|
83
|
-
portions of the Library contained in the Combined Work and reverse
|
|
84
|
-
engineering for debugging such modifications, if you also do each of
|
|
85
|
-
the following:
|
|
86
|
-
|
|
87
|
-
a) Give prominent notice with each copy of the Combined Work that
|
|
88
|
-
the Library is used in it and that the Library and its use are
|
|
89
|
-
covered by this License.
|
|
90
|
-
|
|
91
|
-
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
|
92
|
-
document.
|
|
93
|
-
|
|
94
|
-
c) For a Combined Work that displays copyright notices during
|
|
95
|
-
execution, include the copyright notice for the Library among
|
|
96
|
-
these notices, as well as a reference directing the user to the
|
|
97
|
-
copies of the GNU GPL and this license document.
|
|
98
|
-
|
|
99
|
-
d) Do one of the following:
|
|
100
|
-
|
|
101
|
-
0) Convey the Minimal Corresponding Source under the terms of this
|
|
102
|
-
License, and the Corresponding Application Code in a form
|
|
103
|
-
suitable for, and under terms that permit, the user to
|
|
104
|
-
recombine or relink the Application with a modified version of
|
|
105
|
-
the Linked Version to produce a modified Combined Work, in the
|
|
106
|
-
manner specified by section 6 of the GNU GPL for conveying
|
|
107
|
-
Corresponding Source.
|
|
108
|
-
|
|
109
|
-
1) Use a suitable shared library mechanism for linking with the
|
|
110
|
-
Library. A suitable mechanism is one that (a) uses at run time
|
|
111
|
-
a copy of the Library already present on the user's computer
|
|
112
|
-
system, and (b) will operate properly with a modified version
|
|
113
|
-
of the Library that is interface-compatible with the Linked
|
|
114
|
-
Version.
|
|
115
|
-
|
|
116
|
-
e) Provide Installation Information, but only if you would otherwise
|
|
117
|
-
be required to provide such information under section 6 of the
|
|
118
|
-
GNU GPL, and only to the extent that such information is
|
|
119
|
-
necessary to install and execute a modified version of the
|
|
120
|
-
Combined Work produced by recombining or relinking the
|
|
121
|
-
Application with a modified version of the Linked Version. (If
|
|
122
|
-
you use option 4d0, the Installation Information must accompany
|
|
123
|
-
the Minimal Corresponding Source and Corresponding Application
|
|
124
|
-
Code. If you use option 4d1, you must provide the Installation
|
|
125
|
-
Information in the manner specified by section 6 of the GNU GPL
|
|
126
|
-
for conveying Corresponding Source.)
|
|
127
|
-
|
|
128
|
-
5. Combined Libraries.
|
|
129
|
-
|
|
130
|
-
You may place library facilities that are a work based on the
|
|
131
|
-
Library side by side in a single library together with other library
|
|
132
|
-
facilities that are not Applications and are not covered by this
|
|
133
|
-
License, and convey such a combined library under terms of your
|
|
134
|
-
choice, if you do both of the following:
|
|
135
|
-
|
|
136
|
-
a) Accompany the combined library with a copy of the same work based
|
|
137
|
-
on the Library, uncombined with any other library facilities,
|
|
138
|
-
conveyed under the terms of this License.
|
|
139
|
-
|
|
140
|
-
b) Give prominent notice with the combined library that part of it
|
|
141
|
-
is a work based on the Library, and explaining where to find the
|
|
142
|
-
accompanying uncombined form of the same work.
|
|
143
|
-
|
|
144
|
-
6. Revised Versions of the GNU Lesser General Public License.
|
|
145
|
-
|
|
146
|
-
The Free Software Foundation may publish revised and/or new versions
|
|
147
|
-
of the GNU Lesser General Public License from time to time. Such new
|
|
148
|
-
versions will be similar in spirit to the present version, but may
|
|
149
|
-
differ in detail to address new problems or concerns.
|
|
150
|
-
|
|
151
|
-
Each version is given a distinguishing version number. If the
|
|
152
|
-
Library as you received it specifies that a certain numbered version
|
|
153
|
-
of the GNU Lesser General Public License "or any later version"
|
|
154
|
-
applies to it, you have the option of following the terms and
|
|
155
|
-
conditions either of that published version or of any later version
|
|
156
|
-
published by the Free Software Foundation. If the Library as you
|
|
157
|
-
received it does not specify a version number of the GNU Lesser
|
|
158
|
-
General Public License, you may choose any version of the GNU Lesser
|
|
159
|
-
General Public License ever published by the Free Software Foundation.
|
|
160
|
-
|
|
161
|
-
If the Library as you received it specifies that a proxy can decide
|
|
162
|
-
whether future versions of the GNU Lesser General Public License shall
|
|
163
|
-
apply, that proxy's public statement of acceptance of any version is
|
|
164
|
-
permanent authorization for you to choose that version for the
|
|
165
|
-
Library.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Harrison and the Meith contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -101,6 +101,12 @@ export default async function AdminPromotionsPage() {
|
|
|
101
101
|
{translator.t('adminGroups.examined', { count: result.examined })}
|
|
102
102
|
</p>
|
|
103
103
|
|
|
104
|
+
{result.complete ? null : (
|
|
105
|
+
<p className={PANEL_NOTE}>
|
|
106
|
+
{translator.t('adminGroups.previewCapped', { count: result.examined })}
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
104
110
|
{result.outcomes.length === 0 ? (
|
|
105
111
|
<p className={PANEL_NOTE}>
|
|
106
112
|
{stored.length === 0
|
|
@@ -8,16 +8,23 @@ import { bearerSecret, secretMatches } from '@/server/secret-auth'
|
|
|
8
8
|
|
|
9
9
|
export const dynamic = 'force-dynamic'
|
|
10
10
|
export const revalidate = 0
|
|
11
|
+
export const maxDuration = 300
|
|
11
12
|
|
|
12
13
|
const SECRET_HEADER = 'x-tick-secret'
|
|
13
14
|
|
|
15
|
+
function accepted(): string[] {
|
|
16
|
+
return [env.TICK_SECRET, env.CRON_SECRET].filter(
|
|
17
|
+
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
function authorised(request: Request): boolean {
|
|
15
|
-
const expected =
|
|
22
|
+
const expected = accepted()
|
|
16
23
|
|
|
17
|
-
if (
|
|
24
|
+
if (expected.length === 0) {
|
|
18
25
|
logger().warn(
|
|
19
|
-
'TICK_SECRET is
|
|
20
|
-
'permitted in development only.',
|
|
26
|
+
'Neither TICK_SECRET nor CRON_SECRET is set; the tick endpoint is unauthenticated. This is' +
|
|
27
|
+
' permitted in development only.',
|
|
21
28
|
)
|
|
22
29
|
return true
|
|
23
30
|
}
|
|
@@ -28,7 +35,14 @@ function authorised(request: Request): boolean {
|
|
|
28
35
|
)
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
|
|
38
|
+
const presented = bearerSecret(request, SECRET_HEADER)
|
|
39
|
+
|
|
40
|
+
let matched = false
|
|
41
|
+
for (const candidate of expected) {
|
|
42
|
+
if (secretMatches(presented, candidate)) matched = true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return matched
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
async function runTick(request: Request): Promise<NextResponse> {
|
|
@@ -60,11 +74,14 @@ async function runTick(request: Request): Promise<NextResponse> {
|
|
|
60
74
|
|
|
61
75
|
const failed = outcomes.filter((o) => o.status === 'failed')
|
|
62
76
|
|
|
63
|
-
return NextResponse.json(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
return NextResponse.json(
|
|
78
|
+
{
|
|
79
|
+
ok: failed.length === 0,
|
|
80
|
+
ran: outcomes,
|
|
81
|
+
registered: scheduler.tasks.length,
|
|
82
|
+
},
|
|
83
|
+
{ status: 200 },
|
|
84
|
+
)
|
|
68
85
|
})
|
|
69
86
|
}
|
|
70
87
|
|
package/bin/forum-web.mjs
CHANGED
|
@@ -28,7 +28,32 @@
|
|
|
28
28
|
* (`<root>/.meith/app`) on purpose: `next.config.mjs` (copied verbatim, see
|
|
29
29
|
* below) computes its own workspace root as two directories up from itself,
|
|
30
30
|
* so materializing at that exact depth keeps that computation correct
|
|
31
|
-
* without touching the file.
|
|
31
|
+
* without touching the file. This bin now also passes that root explicitly,
|
|
32
|
+
* as `FORUM_WORKSPACE_ROOT`, so the depth is what keeps the *copied file's*
|
|
33
|
+
* own default honest rather than what the build depends on.
|
|
34
|
+
*
|
|
35
|
+
* `--at-root` materializes into the workspace root itself instead
|
|
36
|
+
* (`<root>`, depth zero), which is the one thing Vercel's Next.js preset
|
|
37
|
+
* needs and `.meith/app` cannot give it: the build artefact at
|
|
38
|
+
* `<root>/.next`, where that builder looks and where, for a Next.js project,
|
|
39
|
+
* it cannot be told to look elsewhere. Nothing about the seam changes — the
|
|
40
|
+
* generated tsconfig's `paths` name `./community.config.ts` instead of
|
|
41
|
+
* `../../community.config.ts`, and every other path in here is computed from
|
|
42
|
+
* `appDir` rather than assumed. Because that mode writes framework-owned
|
|
43
|
+
* names (`app/`, `src/`, `next.config.mjs`, ...) into a directory the board
|
|
44
|
+
* also keeps its own files in, ownership there is decided per *file* — the
|
|
45
|
+
* files it wrote last time, recorded in `.meith/materialized.json`, plus any
|
|
46
|
+
* whose contents are already byte for byte what it would write. Everything
|
|
47
|
+
* else is the board's: never removed, never overwritten, and a collision
|
|
48
|
+
* stops the build naming every file involved. A board can therefore keep
|
|
49
|
+
* `public/ads.txt` beside the shipped `public/sw.js`.
|
|
50
|
+
*
|
|
51
|
+
* `GENERATED_ENTRIES` are the exception, and have to be: `tsconfig.json` and
|
|
52
|
+
* `next-env.d.ts` are written from scratch rather than copied, so there is no
|
|
53
|
+
* shipped file to compare a board's own against and nothing to tell one apart
|
|
54
|
+
* from a stale one this bin wrote. Both are replaced without asking, so a
|
|
55
|
+
* board cannot keep its own compiler options at the root of an `--at-root`
|
|
56
|
+
* workspace. See docs/development.md, "Consuming the board from a workspace".
|
|
32
57
|
*
|
|
33
58
|
* This assumes a *hoisted* `node_modules` (npm, yarn classic, or pnpm with
|
|
34
59
|
* `node-linker=hoisted`) — see docs/development.md for why.
|
|
@@ -39,21 +64,29 @@
|
|
|
39
64
|
* (non-hoisted) pnpm install, nested two directories deeper again. Two
|
|
40
65
|
* environment variables, set only by that workspace's own `build`/`dev`
|
|
41
66
|
* scripts, cover the difference without changing anything for a real
|
|
42
|
-
* external board, which
|
|
67
|
+
* external board, which sets neither itself: `FORUM_WORKSPACE_ROOT`
|
|
43
68
|
* (apps/community/next.config.mjs) points tracing at this repository's real
|
|
44
69
|
* root, and `FORUM_ALIASES_FROM` (`monorepoAliases()` below) carries this
|
|
45
70
|
* repository's own `@meith/*` tsconfig aliases into the generated tsconfig,
|
|
46
71
|
* since packages here resolve each other through those aliases rather than
|
|
47
72
|
* through real `dependencies` entries a hoisted `node_modules` would need.
|
|
73
|
+
* `FORUM_WORKSPACE_ROOT` is always exported onward from here, defaulting to
|
|
74
|
+
* the invoking workspace's own root when that workspace did not set it, so
|
|
75
|
+
* that everything downstream — the copied `next.config.mjs`, and the
|
|
76
|
+
* `@source` rebase below — reads one answer rather than each re-deriving it
|
|
77
|
+
* from where it happens to sit.
|
|
48
78
|
*/
|
|
49
79
|
import { spawn } from 'node:child_process'
|
|
50
80
|
import {
|
|
51
81
|
cpSync,
|
|
52
82
|
existsSync,
|
|
53
83
|
mkdirSync,
|
|
84
|
+
readdirSync,
|
|
54
85
|
readFileSync,
|
|
55
86
|
realpathSync,
|
|
87
|
+
rmdirSync,
|
|
56
88
|
rmSync,
|
|
89
|
+
statSync,
|
|
57
90
|
writeFileSync,
|
|
58
91
|
} from 'node:fs'
|
|
59
92
|
import { createRequire } from 'node:module'
|
|
@@ -63,11 +96,14 @@ import { fileURLToPath } from 'node:url'
|
|
|
63
96
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
64
97
|
const packageRoot = join(here, '..')
|
|
65
98
|
const workspaceRoot = process.cwd()
|
|
66
|
-
const
|
|
99
|
+
const AT_ROOT_FLAG = '--at-root'
|
|
100
|
+
const atRoot = process.argv.includes(AT_ROOT_FLAG)
|
|
101
|
+
const appDir = atRoot ? workspaceRoot : join(workspaceRoot, '.meith', 'app')
|
|
67
102
|
|
|
68
103
|
for (const name of ['FORUM_WORKSPACE_ROOT', 'FORUM_ALIASES_FROM']) {
|
|
69
104
|
if (process.env[name]) process.env[name] = resolve(workspaceRoot, process.env[name])
|
|
70
105
|
}
|
|
106
|
+
if (!process.env.FORUM_WORKSPACE_ROOT) process.env.FORUM_WORKSPACE_ROOT = workspaceRoot
|
|
71
107
|
|
|
72
108
|
/**
|
|
73
109
|
* Files this package ships (see its `files` allowlist) that belong inside the
|
|
@@ -91,6 +127,180 @@ function fail(message) {
|
|
|
91
127
|
process.exit(1)
|
|
92
128
|
}
|
|
93
129
|
|
|
130
|
+
const GENERATED_ENTRIES = ['tsconfig.json', 'next-env.d.ts']
|
|
131
|
+
const SHARED_ENTRIES = ['public']
|
|
132
|
+
const GLOBALS_CSS = 'src/styles/globals.css'
|
|
133
|
+
const MATERIALIZED_RECORD = join(workspaceRoot, '.meith', 'materialized.json')
|
|
134
|
+
|
|
135
|
+
function walkFiles(dir, prefix, into) {
|
|
136
|
+
for (const item of readdirSync(dir, { withFileTypes: true })) {
|
|
137
|
+
const rel = prefix === '' ? item.name : `${prefix}/${item.name}`
|
|
138
|
+
if (item.isDirectory()) walkFiles(join(dir, item.name), rel, into)
|
|
139
|
+
else into.push(rel)
|
|
140
|
+
}
|
|
141
|
+
return into
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every file `--at-root` is about to write into the workspace root, as posix
|
|
146
|
+
* relative paths: the shipped entries expanded file by file, plus the two
|
|
147
|
+
* this bin generates. Ownership is decided per file rather than per
|
|
148
|
+
* top-level name so that a board can keep its own files inside a directory
|
|
149
|
+
* the framework also writes into — `public/ads.txt` beside the shipped
|
|
150
|
+
* `public/sw.js`. See docs/development.md, "Consuming the board from a
|
|
151
|
+
* workspace".
|
|
152
|
+
*/
|
|
153
|
+
function intendedRootFiles() {
|
|
154
|
+
const files = []
|
|
155
|
+
for (const entry of APP_ENTRIES) {
|
|
156
|
+
const source = join(packageRoot, entry)
|
|
157
|
+
if (!existsSync(source)) continue
|
|
158
|
+
if (statSync(source).isDirectory()) walkFiles(source, entry, files)
|
|
159
|
+
else files.push(entry)
|
|
160
|
+
}
|
|
161
|
+
return [...files, ...GENERATED_ENTRIES]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readMaterializedRecord() {
|
|
165
|
+
if (!existsSync(MATERIALIZED_RECORD)) return []
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(readFileSync(MATERIALIZED_RECORD, 'utf8')).files ?? []
|
|
168
|
+
} catch {
|
|
169
|
+
return []
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function absoluteRootPath(rel) {
|
|
174
|
+
return join(workspaceRoot, ...rel.split('/'))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* What this bin would leave at `rel`, which is the shipped file's own bytes
|
|
179
|
+
* for everything except `globals.css` — that one is rewritten in place after
|
|
180
|
+
* the copy (`rewriteGlobalsCssSourcePaths`), so comparing a materialized
|
|
181
|
+
* tree against the package source would report the one file this bin always
|
|
182
|
+
* edits as though the board had edited it.
|
|
183
|
+
*/
|
|
184
|
+
function materializedContent(rel) {
|
|
185
|
+
const source = readFileSync(join(packageRoot, ...rel.split('/')))
|
|
186
|
+
if (rel !== GLOBALS_CSS) return source
|
|
187
|
+
return Buffer.from(
|
|
188
|
+
rebaseGlobalsCssSources(
|
|
189
|
+
source.toString('utf8'),
|
|
190
|
+
dirname(absoluteRootPath(rel)),
|
|
191
|
+
process.env.FORUM_WORKSPACE_ROOT,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function alreadyMaterialized(rel) {
|
|
197
|
+
try {
|
|
198
|
+
return readFileSync(absoluteRootPath(rel)).equals(materializedContent(rel))
|
|
199
|
+
} catch {
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pruneEmptyDirectories(rel) {
|
|
205
|
+
let current = dirname(absoluteRootPath(rel))
|
|
206
|
+
while (current !== workspaceRoot && current.startsWith(workspaceRoot)) {
|
|
207
|
+
try {
|
|
208
|
+
rmdirSync(current)
|
|
209
|
+
} catch {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
current = dirname(current)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `--at-root` only; every other mode owns `.meith/app` outright and replaces
|
|
218
|
+
* it wholesale. A file this bin is about to write is its own to replace when
|
|
219
|
+
* the record says it wrote that file before, or when what is on disk is byte
|
|
220
|
+
* for byte what it would write anyway — the second case being a checkout
|
|
221
|
+
* that committed a materialized file, where no record exists and refusing
|
|
222
|
+
* would fail the deploy over a file identical to the one being written.
|
|
223
|
+
* Anything else is the board's, and the board's is never overwritten —
|
|
224
|
+
* except `GENERATED_ENTRIES`, which are written rather than copied and so
|
|
225
|
+
* can satisfy neither test; those are replaced unconditionally.
|
|
226
|
+
*/
|
|
227
|
+
function claimRootFiles(intended) {
|
|
228
|
+
const owned = new Set(readMaterializedRecord())
|
|
229
|
+
|
|
230
|
+
const collisions = intended.filter((rel) => {
|
|
231
|
+
if (!existsSync(absoluteRootPath(rel))) return false
|
|
232
|
+
if (owned.has(rel)) return false
|
|
233
|
+
if (GENERATED_ENTRIES.includes(rel)) return false
|
|
234
|
+
return !alreadyMaterialized(rel)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
if (collisions.length > 0) {
|
|
238
|
+
fail(
|
|
239
|
+
`refusing to overwrite ${collisions.length} file(s) in ${workspaceRoot} that ` +
|
|
240
|
+
`"${AT_ROOT_FLAG}" did not write:\n` +
|
|
241
|
+
collisions.map((rel) => ` ${rel}`).join('\n') +
|
|
242
|
+
`\nThis mode materializes @meith/web's own app into this directory, and each of ` +
|
|
243
|
+
"these is either the board's own file under a name the framework ships, or a " +
|
|
244
|
+
'materialized file that has been edited since. Move it aside, or drop the flag ' +
|
|
245
|
+
'to materialize into .meith/app instead.',
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Files the previous run wrote and this one will not: the framework stopped
|
|
252
|
+
* shipping them. They were this bin's own, so they go — nothing the board
|
|
253
|
+
* added is in the record, which is why removal is driven from it rather than
|
|
254
|
+
* from the directory.
|
|
255
|
+
*/
|
|
256
|
+
function removeStaleRootFiles(intended) {
|
|
257
|
+
const keeping = new Set(intended)
|
|
258
|
+
for (const rel of readMaterializedRecord()) {
|
|
259
|
+
if (keeping.has(rel)) continue
|
|
260
|
+
rmSync(absoluteRootPath(rel), { recursive: true, force: true })
|
|
261
|
+
pruneEmptyDirectories(rel)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function recordRootFiles(intended) {
|
|
266
|
+
mkdirSync(dirname(MATERIALIZED_RECORD), { recursive: true })
|
|
267
|
+
writeFileSync(MATERIALIZED_RECORD, `${JSON.stringify({ files: intended }, null, 2)}\n`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Files the board itself put under a directory the framework owns outright.
|
|
272
|
+
* Nothing here removes them — ownership is per file, so they simply survive —
|
|
273
|
+
* but a scaffolded board gitignores those directories as a unit, so a route
|
|
274
|
+
* added under `app/` works locally, is never committed, and is absent from a
|
|
275
|
+
* deploy built out of the checkout. Warned about rather than refused: the
|
|
276
|
+
* file is the author's to keep, and this is the only moment anything looks at
|
|
277
|
+
* it. `public/` is excluded because that one is shared on purpose.
|
|
278
|
+
*/
|
|
279
|
+
function warnAboutStrayBoardFiles(intended) {
|
|
280
|
+
const written = new Set(intended)
|
|
281
|
+
const owned = APP_ENTRIES.filter((entry) => !SHARED_ENTRIES.includes(entry))
|
|
282
|
+
const strays = []
|
|
283
|
+
|
|
284
|
+
for (const entry of owned) {
|
|
285
|
+
const dir = join(workspaceRoot, entry)
|
|
286
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue
|
|
287
|
+
for (const rel of walkFiles(dir, entry, [])) {
|
|
288
|
+
if (!written.has(rel)) strays.push(rel)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (strays.length === 0) return
|
|
293
|
+
|
|
294
|
+
console.warn(
|
|
295
|
+
`forum-web: ${strays.length} file(s) here are not @meith/web's, under a directory that is:\n` +
|
|
296
|
+
`${strays.map((rel) => ` ${rel}`).join('\n')}\n` +
|
|
297
|
+
'They are left exactly as they are. A scaffolded board gitignores these directories as ' +
|
|
298
|
+
'a unit, though, so nothing above is committed, and a deploy that builds from the ' +
|
|
299
|
+
'checkout will not see it. A board extends the forum through plugins and themes ' +
|
|
300
|
+
'(docs/plugin-api.md, docs/theme-api.md), not by adding files here.',
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
94
304
|
function toPosixRelative(from, to) {
|
|
95
305
|
const rel = relative(from, to).split(sep).join('/')
|
|
96
306
|
return rel.startsWith('.') ? rel : `./${rel}`
|
|
@@ -104,14 +314,14 @@ export function rebaseGlobalsCssSources(css, cssDir, workspaceRootOverride) {
|
|
|
104
314
|
}
|
|
105
315
|
|
|
106
316
|
function rewriteGlobalsCssSourcePaths() {
|
|
107
|
-
const
|
|
108
|
-
if (!workspaceRootOverride) return
|
|
109
|
-
|
|
110
|
-
const cssPath = join(appDir, 'src', 'styles', 'globals.css')
|
|
317
|
+
const cssPath = join(appDir, ...GLOBALS_CSS.split('/'))
|
|
111
318
|
if (!existsSync(cssPath)) return
|
|
112
319
|
|
|
113
320
|
const css = readFileSync(cssPath, 'utf8')
|
|
114
|
-
writeFileSync(
|
|
321
|
+
writeFileSync(
|
|
322
|
+
cssPath,
|
|
323
|
+
rebaseGlobalsCssSources(css, dirname(cssPath), process.env.FORUM_WORKSPACE_ROOT),
|
|
324
|
+
)
|
|
115
325
|
}
|
|
116
326
|
|
|
117
327
|
/**
|
|
@@ -172,12 +382,18 @@ function materialize() {
|
|
|
172
382
|
)
|
|
173
383
|
}
|
|
174
384
|
|
|
385
|
+
const rootFiles = atRoot ? intendedRootFiles() : null
|
|
386
|
+
if (rootFiles) {
|
|
387
|
+
claimRootFiles(rootFiles)
|
|
388
|
+
removeStaleRootFiles(rootFiles)
|
|
389
|
+
}
|
|
390
|
+
|
|
175
391
|
mkdirSync(appDir, { recursive: true })
|
|
176
392
|
|
|
177
393
|
for (const entry of APP_ENTRIES) {
|
|
178
394
|
const source = join(packageRoot, entry)
|
|
179
395
|
const target = join(appDir, entry)
|
|
180
|
-
rmSync(target, { recursive: true, force: true })
|
|
396
|
+
if (!atRoot) rmSync(target, { recursive: true, force: true })
|
|
181
397
|
if (!existsSync(source)) continue
|
|
182
398
|
cpSync(source, target, { recursive: true })
|
|
183
399
|
}
|
|
@@ -217,6 +433,11 @@ function materialize() {
|
|
|
217
433
|
join(appDir, 'next-env.d.ts'),
|
|
218
434
|
'/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n',
|
|
219
435
|
)
|
|
436
|
+
|
|
437
|
+
if (rootFiles) {
|
|
438
|
+
recordRootFiles(rootFiles)
|
|
439
|
+
warnAboutStrayBoardFiles(rootFiles)
|
|
440
|
+
}
|
|
220
441
|
}
|
|
221
442
|
|
|
222
443
|
/**
|
|
@@ -265,10 +486,10 @@ function run(executable, args, cwd, onSuccess) {
|
|
|
265
486
|
}
|
|
266
487
|
|
|
267
488
|
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
268
|
-
const [
|
|
489
|
+
const [command, ...rest] = process.argv.slice(2).filter((argument) => argument !== AT_ROOT_FLAG)
|
|
269
490
|
|
|
270
491
|
if (!['dev', 'build', 'start'].includes(command ?? '')) {
|
|
271
|
-
console.error(
|
|
492
|
+
console.error(`Usage: forum-web <dev|build|start> [${AT_ROOT_FLAG}] [next arguments]`)
|
|
272
493
|
process.exit(1)
|
|
273
494
|
}
|
|
274
495
|
|
package/bin/forum-web.test.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
2
8
|
|
|
3
9
|
// @ts-expect-error forum-web.mjs ships untyped, imported directly for its pure export
|
|
4
10
|
import { rebaseGlobalsCssSources } from './forum-web.mjs'
|
|
@@ -28,6 +34,35 @@ describe('rebaseGlobalsCssSources', () => {
|
|
|
28
34
|
expect(rewritten).toContain('.foo { color: red; }')
|
|
29
35
|
})
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Against the real shipped file, not a fixture: this rebase now runs on
|
|
39
|
+
* every materialization including the default one, where it must be a
|
|
40
|
+
* no-op. Every `@source` in that file is written relative to the workspace
|
|
41
|
+
* root, and this only stays a no-op while that holds — a `@source` meaning
|
|
42
|
+
* anything else (`../fonts`, for `src/fonts`) would be silently retargeted
|
|
43
|
+
* at the workspace root, and fails here instead.
|
|
44
|
+
*/
|
|
45
|
+
const shippedGlobalsCss = readFileSync(
|
|
46
|
+
new URL('../src/styles/globals.css', import.meta.url),
|
|
47
|
+
'utf8',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
it('leaves the shipped globals.css byte for byte alone at the default depth', () => {
|
|
51
|
+
expect(
|
|
52
|
+
rebaseGlobalsCssSources(shippedGlobalsCss, '/board/.meith/app/src/styles', '/board'),
|
|
53
|
+
).toBe(shippedGlobalsCss)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('rebases every shipped @source against the board root at depth zero', () => {
|
|
57
|
+
const rewritten = rebaseGlobalsCssSources(shippedGlobalsCss, '/board/src/styles', '/board')
|
|
58
|
+
const sources = [...rewritten.matchAll(/@source "([^"]+)";/g)].map((match) => match[1])
|
|
59
|
+
|
|
60
|
+
expect(sources.length).toBeGreaterThan(0)
|
|
61
|
+
expect(sources).toContain('../../themes')
|
|
62
|
+
expect(sources).toContain('../../packages/ui/src')
|
|
63
|
+
for (const source of sources) expect(source.startsWith('../../')).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
31
66
|
it('is a no-op for a file with no @source lines', () => {
|
|
32
67
|
const css = '@import "tailwindcss";\n.foo { color: red; }\n'
|
|
33
68
|
expect(rebaseGlobalsCssSources(css, '/repo/boards/stock/.meith/app/src/styles', '/repo')).toBe(
|
|
@@ -35,3 +70,131 @@ describe('rebaseGlobalsCssSources', () => {
|
|
|
35
70
|
)
|
|
36
71
|
})
|
|
37
72
|
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `forum-web start --at-root` materializes and then stops, because there is
|
|
76
|
+
* no standalone build for it to exec — which makes it the whole
|
|
77
|
+
* materialization path in under a tenth of a second, with no `next build`
|
|
78
|
+
* anywhere near it. Materialization is the part that has been wrong; the
|
|
79
|
+
* build is not.
|
|
80
|
+
*/
|
|
81
|
+
const BIN = fileURLToPath(new URL('./forum-web.mjs', import.meta.url))
|
|
82
|
+
|
|
83
|
+
function materializeAtRoot(dir: string) {
|
|
84
|
+
const result = spawnSync(process.execPath, [BIN, 'start', '--at-root'], {
|
|
85
|
+
cwd: dir,
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
})
|
|
88
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
|
|
89
|
+
return {
|
|
90
|
+
output,
|
|
91
|
+
refused: output.includes('refusing to overwrite'),
|
|
92
|
+
materialized: output.includes('no standalone build'),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('materializing at the workspace root', () => {
|
|
97
|
+
const boards: string[] = []
|
|
98
|
+
|
|
99
|
+
afterAll(() => {
|
|
100
|
+
for (const dir of boards) rmSync(dir, { recursive: true, force: true })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
function board(): string {
|
|
104
|
+
const dir = mkdtempSync(join(tmpdir(), 'forum-web-at-root-'))
|
|
105
|
+
writeFileSync(join(dir, 'community.config.ts'), 'export default {}\n')
|
|
106
|
+
writeFileSync(join(dir, 'community.plugins.ts'), 'export const INSTALLED_PLUGINS = []\n')
|
|
107
|
+
boards.push(dir)
|
|
108
|
+
return dir
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('writes the app into the workspace root and records every file it wrote', () => {
|
|
112
|
+
const dir = board()
|
|
113
|
+
|
|
114
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
115
|
+
|
|
116
|
+
for (const rel of ['next.config.mjs', 'app', 'src/styles/globals.css', 'public/sw.js']) {
|
|
117
|
+
expect(existsSync(join(dir, rel))).toBe(true)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const record = JSON.parse(readFileSync(join(dir, '.meith/materialized.json'), 'utf8'))
|
|
121
|
+
expect(record.files).toContain('public/sw.js')
|
|
122
|
+
expect(record.files).toContain('next.config.mjs')
|
|
123
|
+
expect(record.files).toContain('tsconfig.json')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('leaves a board file under a shared directory alone on a second run', () => {
|
|
127
|
+
const dir = board()
|
|
128
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
129
|
+
|
|
130
|
+
writeFileSync(join(dir, 'public/ads.txt'), 'board-owned\n')
|
|
131
|
+
mkdirSync(join(dir, 'public/.well-known'), { recursive: true })
|
|
132
|
+
writeFileSync(join(dir, 'public/.well-known/thing'), 'verify\n')
|
|
133
|
+
|
|
134
|
+
const second = materializeAtRoot(dir)
|
|
135
|
+
|
|
136
|
+
expect(second.refused).toBe(false)
|
|
137
|
+
expect(second.materialized).toBe(true)
|
|
138
|
+
expect(readFileSync(join(dir, 'public/ads.txt'), 'utf8')).toBe('board-owned\n')
|
|
139
|
+
expect(readFileSync(join(dir, 'public/.well-known/thing'), 'utf8')).toBe('verify\n')
|
|
140
|
+
expect(existsSync(join(dir, 'public/sw.js'))).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('refuses a board file under a name the framework ships, before writing anything', () => {
|
|
144
|
+
const dir = board()
|
|
145
|
+
writeFileSync(join(dir, 'instrumentation.ts'), 'export function register() {}\n')
|
|
146
|
+
|
|
147
|
+
const result = materializeAtRoot(dir)
|
|
148
|
+
|
|
149
|
+
expect(result.refused).toBe(true)
|
|
150
|
+
expect(result.output).toContain('instrumentation.ts')
|
|
151
|
+
expect(readFileSync(join(dir, 'instrumentation.ts'), 'utf8')).toBe(
|
|
152
|
+
'export function register() {}\n',
|
|
153
|
+
)
|
|
154
|
+
expect(existsSync(join(dir, 'app'))).toBe(false)
|
|
155
|
+
expect(existsSync(join(dir, 'next.config.mjs'))).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('proceeds on a fresh checkout whose materialized files are committed', () => {
|
|
159
|
+
const dir = board()
|
|
160
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
161
|
+
|
|
162
|
+
rmSync(join(dir, '.meith'), { recursive: true, force: true })
|
|
163
|
+
const second = materializeAtRoot(dir)
|
|
164
|
+
|
|
165
|
+
expect(second.refused).toBe(false)
|
|
166
|
+
expect(second.materialized).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('removes a file it recorded and no longer ships, and nothing else', () => {
|
|
170
|
+
const dir = board()
|
|
171
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
172
|
+
|
|
173
|
+
const recordPath = join(dir, '.meith/materialized.json')
|
|
174
|
+
const record = JSON.parse(readFileSync(recordPath, 'utf8'))
|
|
175
|
+
writeFileSync(join(dir, 'src/dropped-by-a-release.ts'), 'stale\n')
|
|
176
|
+
writeFileSync(join(dir, 'src/mine.ts'), 'board\n')
|
|
177
|
+
record.files.push('src/dropped-by-a-release.ts')
|
|
178
|
+
writeFileSync(recordPath, JSON.stringify(record))
|
|
179
|
+
|
|
180
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
181
|
+
|
|
182
|
+
expect(existsSync(join(dir, 'src/dropped-by-a-release.ts'))).toBe(false)
|
|
183
|
+
expect(readFileSync(join(dir, 'src/mine.ts'), 'utf8')).toBe('board\n')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('warns about a board file under a directory the framework owns outright', () => {
|
|
187
|
+
const dir = board()
|
|
188
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
189
|
+
|
|
190
|
+
mkdirSync(join(dir, 'app/custom'), { recursive: true })
|
|
191
|
+
writeFileSync(join(dir, 'app/custom/page.tsx'), 'export default () => null\n')
|
|
192
|
+
|
|
193
|
+
const second = materializeAtRoot(dir)
|
|
194
|
+
|
|
195
|
+
expect(second.refused).toBe(false)
|
|
196
|
+
expect(second.output).toContain('app/custom/page.tsx')
|
|
197
|
+
expect(second.output).toContain("not @meith/web's")
|
|
198
|
+
expect(existsSync(join(dir, 'app/custom/page.tsx'))).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
})
|
package/next.config.mjs
CHANGED
|
@@ -5,10 +5,15 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Two directories up from this file's own location by default — correct
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
8
|
+
* Two directories up from this file's own location by default — correct
|
|
9
|
+
* where `forum-web` materializes at `.meith/app` and where this file sits in
|
|
10
|
+
* `apps/community` unmaterialized. `boards/stock` and `forum-web --at-root`
|
|
11
|
+
* are both at other depths, and neither relies on this default:
|
|
12
|
+
* `FORUM_WORKSPACE_ROOT` is set for the first by that board's own scripts
|
|
13
|
+
* and passed on by `forum-web` in every case, so this fallback only applies
|
|
14
|
+
* when the file is read outside that bin entirely (see
|
|
15
|
+
* docs/architecture.md, "The stock board"; docs/development.md, "Consuming
|
|
16
|
+
* the board from a workspace").
|
|
12
17
|
*/
|
|
13
18
|
const workspaceRoot = process.env.FORUM_WORKSPACE_ROOT
|
|
14
19
|
? path.resolve(process.env.FORUM_WORKSPACE_ROOT)
|
|
@@ -18,9 +23,12 @@ const workspaceRoot = process.env.FORUM_WORKSPACE_ROOT
|
|
|
18
23
|
* The relative equivalent of `workspaceRoot`, for `outputFileTracingIncludes`
|
|
19
24
|
* below — see docs/development.md, "Consuming the board from a workspace",
|
|
20
25
|
* for why that option needs a path relative to this file rather than an
|
|
21
|
-
* absolute one.
|
|
26
|
+
* absolute one. It is `.` rather than the empty string when this file already
|
|
27
|
+
* sits at the workspace root, which is what `forum-web build --at-root`
|
|
28
|
+
* materializes (same section) — an empty prefix would make the glob below
|
|
29
|
+
* read as an absolute path and match nothing.
|
|
22
30
|
*/
|
|
23
|
-
const upToWorkspaceRoot = path.relative(here, workspaceRoot).split(path.sep).join('/')
|
|
31
|
+
const upToWorkspaceRoot = path.relative(here, workspaceRoot).split(path.sep).join('/') || '.'
|
|
24
32
|
|
|
25
33
|
const loadedEnvFiles = []
|
|
26
34
|
for (const name of ['.env.local', '.env']) {
|
|
@@ -39,13 +47,10 @@ const nextConfig = {
|
|
|
39
47
|
poweredByHeader: false,
|
|
40
48
|
distDir: process.env.FORUM_DIST_DIR ?? '.next',
|
|
41
49
|
|
|
42
|
-
experimental: {
|
|
43
|
-
turbopackFileSystemCacheForDev: false,
|
|
44
|
-
},
|
|
45
|
-
|
|
46
50
|
serverExternalPackages: [
|
|
47
51
|
'@aws-sdk/client-s3',
|
|
48
52
|
'@aws-sdk/s3-request-presigner',
|
|
53
|
+
'@vercel/blob',
|
|
49
54
|
'postgres',
|
|
50
55
|
'@jsquash/jpeg',
|
|
51
56
|
'@jsquash/png',
|
|
@@ -61,6 +66,7 @@ const nextConfig = {
|
|
|
61
66
|
outputFileTracingIncludes: {
|
|
62
67
|
'/**': [
|
|
63
68
|
`${upToWorkspaceRoot}/node_modules/.pnpm/@swc+helpers@0.5.23/node_modules/@swc/helpers/**/*`,
|
|
69
|
+
`${upToWorkspaceRoot}/node_modules/@swc/helpers/**/*`,
|
|
64
70
|
],
|
|
65
71
|
},
|
|
66
72
|
images: {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/meith-dev/meith.git",
|
|
@@ -43,52 +43,52 @@
|
|
|
43
43
|
"typescript": "7.0.2",
|
|
44
44
|
"@types/react": "^19",
|
|
45
45
|
"@types/react-dom": "^19",
|
|
46
|
-
"@meith/
|
|
47
|
-
"@meith/antispam": "0.
|
|
48
|
-
"@meith/
|
|
49
|
-
"@meith/
|
|
50
|
-
"@meith/
|
|
51
|
-
"@meith/
|
|
52
|
-
"@meith/
|
|
53
|
-
"@meith/
|
|
54
|
-
"@meith/
|
|
55
|
-
"@meith/
|
|
56
|
-
"@meith/
|
|
57
|
-
"@meith/
|
|
58
|
-
"@meith/
|
|
59
|
-
"@meith/
|
|
60
|
-
"@meith/
|
|
61
|
-
"@meith/
|
|
62
|
-
"@meith/
|
|
63
|
-
"@meith/
|
|
64
|
-
"@meith/
|
|
65
|
-
"@meith/
|
|
66
|
-
"@meith/marketplace": "0.
|
|
67
|
-
"@meith/
|
|
68
|
-
"@meith/
|
|
69
|
-
"@meith/
|
|
70
|
-
"@meith/
|
|
71
|
-
"@meith/plugin-
|
|
72
|
-
"@meith/
|
|
73
|
-
"@meith/
|
|
74
|
-
"@meith/
|
|
75
|
-
"@meith/
|
|
76
|
-
"@meith/
|
|
77
|
-
"@meith/
|
|
78
|
-
"@meith/
|
|
79
|
-
"@meith/
|
|
80
|
-
"@meith/
|
|
81
|
-
"@meith/
|
|
82
|
-
"@meith/tasks": "0.
|
|
83
|
-
"@meith/theme-clubhouse": "0.
|
|
84
|
-
"@meith/theme-
|
|
85
|
-
"@meith/theme-
|
|
86
|
-
"@meith/theme-
|
|
87
|
-
"@meith/theme-
|
|
88
|
-
"@meith/
|
|
89
|
-
"@meith/threads": "0.
|
|
90
|
-
"@meith/
|
|
91
|
-
"@meith/upgrade": "0.
|
|
46
|
+
"@meith/accounts": "0.18.0",
|
|
47
|
+
"@meith/antispam": "0.18.0",
|
|
48
|
+
"@meith/api": "0.18.0",
|
|
49
|
+
"@meith/authorization": "0.18.0",
|
|
50
|
+
"@meith/attachments": "0.18.0",
|
|
51
|
+
"@meith/avatars": "0.18.0",
|
|
52
|
+
"@meith/admin": "0.18.0",
|
|
53
|
+
"@meith/db": "0.18.0",
|
|
54
|
+
"@meith/demo": "0.18.0",
|
|
55
|
+
"@meith/drafts": "0.18.0",
|
|
56
|
+
"@meith/drivers": "0.18.0",
|
|
57
|
+
"@meith/core": "0.18.0",
|
|
58
|
+
"@meith/events": "0.18.0",
|
|
59
|
+
"@meith/forums": "0.18.0",
|
|
60
|
+
"@meith/groups": "0.18.0",
|
|
61
|
+
"@meith/install": "0.18.0",
|
|
62
|
+
"@meith/i18n": "0.18.0",
|
|
63
|
+
"@meith/mail": "0.18.0",
|
|
64
|
+
"@meith/import": "0.18.0",
|
|
65
|
+
"@meith/markdown": "0.18.0",
|
|
66
|
+
"@meith/marketplace": "0.18.0",
|
|
67
|
+
"@meith/messages": "0.18.0",
|
|
68
|
+
"@meith/moderation": "0.18.0",
|
|
69
|
+
"@meith/plugin-dues": "0.18.0",
|
|
70
|
+
"@meith/notifications": "0.18.0",
|
|
71
|
+
"@meith/plugin-kit": "0.18.0",
|
|
72
|
+
"@meith/relations": "0.18.0",
|
|
73
|
+
"@meith/posts": "0.18.0",
|
|
74
|
+
"@meith/reputation": "0.18.0",
|
|
75
|
+
"@meith/polls": "0.18.0",
|
|
76
|
+
"@meith/runtime": "0.18.0",
|
|
77
|
+
"@meith/profile-fields": "0.18.0",
|
|
78
|
+
"@meith/search": "0.18.0",
|
|
79
|
+
"@meith/settings": "0.18.0",
|
|
80
|
+
"@meith/subscriptions": "0.18.0",
|
|
81
|
+
"@meith/signatures": "0.18.0",
|
|
82
|
+
"@meith/tasks": "0.18.0",
|
|
83
|
+
"@meith/theme-clubhouse": "0.18.0",
|
|
84
|
+
"@meith/theme-kit": "0.18.0",
|
|
85
|
+
"@meith/theme-default": "0.18.0",
|
|
86
|
+
"@meith/theme-phasebook": "0.18.0",
|
|
87
|
+
"@meith/theme-midnight": "0.18.0",
|
|
88
|
+
"@meith/ui": "0.18.0",
|
|
89
|
+
"@meith/threads": "0.18.0",
|
|
90
|
+
"@meith/theme-raidframe": "0.18.0",
|
|
91
|
+
"@meith/upgrade": "0.18.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"dev": "next dev",
|
|
@@ -77,14 +77,10 @@ export function requirePromotionRules(): PostgresPromotionRepository {
|
|
|
77
77
|
return repository
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
export async function previewPromotions(
|
|
80
|
+
export async function previewPromotions(): Promise<PromotionRunResult | null> {
|
|
81
81
|
if (getContainer().dataSource !== 'postgres') return null
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
promotions: new PostgresPromotionRepository(getDb()),
|
|
85
|
-
guards: defaultPromotionGuards(),
|
|
86
|
-
})
|
|
87
|
-
return service.preview(limit)
|
|
83
|
+
return promotionService().preview()
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
export function promotionService(): PromotionService {
|
package/src/server/install.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getDb,
|
|
16
16
|
isInstalled,
|
|
17
17
|
markInstalled,
|
|
18
|
+
migrationUrl,
|
|
18
19
|
PostgresAdminRepository,
|
|
19
20
|
PostgresForumRepository,
|
|
20
21
|
PostgresSettingsRepository,
|
|
@@ -126,7 +127,7 @@ async function forgetCachedBoard(): Promise<void> {
|
|
|
126
127
|
export type InstallRun = { readonly sealed: true } | { readonly report: readonly StepOutcome[] }
|
|
127
128
|
|
|
128
129
|
export async function runInstall(input: InstallInput): Promise<InstallRun> {
|
|
129
|
-
const url = env
|
|
130
|
+
const url = migrationUrl(env)
|
|
130
131
|
|
|
131
132
|
try {
|
|
132
133
|
const run = await withInstallLock(url, async (): Promise<InstallRun> => {
|
|
@@ -6,7 +6,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
|
|
|
6
6
|
|
|
7
7
|
import { activeDefinitions } from './plugin-host'
|
|
8
8
|
|
|
9
|
-
export const CODE_VERSION = '0.
|
|
9
|
+
export const CODE_VERSION = '0.18.0'
|
|
10
10
|
|
|
11
11
|
export async function pendingUpgradeNotice(): Promise<string | null> {
|
|
12
12
|
if (env.DATA_SOURCE !== 'postgres') return null
|