kempo 4.8.0 → 4.9.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 (62) hide show
  1. package/README.md +7 -3
  2. package/bin/cli.js +23 -1
  3. package/dist/admin/components/Updates.js +117 -0
  4. package/dist/admin/components/Updates.js.map +1 -0
  5. package/dist/admin/nav.fragment.html +6 -0
  6. package/dist/admin/updates/index.page.html +14 -0
  7. package/dist/kempo/api/updates/GET.js +2 -0
  8. package/dist/kempo/api/updates/GET.js.map +1 -0
  9. package/dist/kempo/api/updates/apply/POST.js +2 -0
  10. package/dist/kempo/api/updates/apply/POST.js.map +1 -0
  11. package/dist/kempo/api/updates/check/POST.js +2 -0
  12. package/dist/kempo/api/updates/check/POST.js.map +1 -0
  13. package/dist/kempo/api/updates/discard/POST.js +2 -0
  14. package/dist/kempo/api/updates/discard/POST.js.map +1 -0
  15. package/dist/kempo/api/updates/prepare/POST.js +2 -0
  16. package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
  17. package/dist/kempo/api/updates/rollback/POST.js +2 -0
  18. package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
  19. package/dist/kempo/fetch.js +1 -1
  20. package/dist/kempo/fetch.js.map +1 -1
  21. package/dist/kempo/sdk.js +1 -1
  22. package/dist/kempo/sdk.js.map +1 -1
  23. package/docker/.dockerignore +9 -0
  24. package/docker/Dockerfile +25 -0
  25. package/docker/docker-compose.prod.yml +39 -0
  26. package/docker/entrypoint.mjs +96 -0
  27. package/middleware/kempo.js +53 -3
  28. package/package.json +3 -1
  29. package/server/db/schema.js +12 -0
  30. package/server/db/upgrades/README.md +20 -0
  31. package/server/sdk.js +3 -0
  32. package/server/utils/extensions/checkExtensionUpdate.js +9 -36
  33. package/server/utils/extensions/reconcileExtensions.js +90 -0
  34. package/server/utils/extensions/updateExtension.js +14 -153
  35. package/server/utils/extensions/upgradeExtension.js +117 -0
  36. package/server/utils/extensions/writeSiteFile.js +66 -0
  37. package/server/utils/settings/systemSettings.js +1 -0
  38. package/server/utils/setup/runUpgrades.js +70 -0
  39. package/server/utils/setup/seedDatabase.js +1 -1
  40. package/server/utils/setup/seedIfNew.js +21 -0
  41. package/server/utils/setup/syncCoreSchema.js +130 -0
  42. package/server/utils/setup/systemPermissions.js +2 -0
  43. package/server/utils/updates/applyStagedUpdate.js +95 -0
  44. package/server/utils/updates/applyUpdate.js +76 -0
  45. package/server/utils/updates/backupDatabase.js +35 -0
  46. package/server/utils/updates/discardUpdate.js +11 -0
  47. package/server/utils/updates/extensionTargets.js +101 -0
  48. package/server/utils/updates/fetchManifest.js +26 -0
  49. package/server/utils/updates/getExtensionUpdates.js +70 -0
  50. package/server/utils/updates/getInstallInfo.js +25 -0
  51. package/server/utils/updates/getUpdateStatus.js +57 -0
  52. package/server/utils/updates/helpers.js +30 -0
  53. package/server/utils/updates/notifySchemaAttention.js +35 -0
  54. package/server/utils/updates/npm.js +41 -0
  55. package/server/utils/updates/prepareUpdate.js +109 -0
  56. package/server/utils/updates/releaseStatus.js +23 -0
  57. package/server/utils/updates/restart.js +64 -0
  58. package/server/utils/updates/rollbackUpdate.js +56 -0
  59. package/server/utils/updates/runUpdateCheck.js +52 -0
  60. package/server/utils/updates/runtime.js +15 -0
  61. package/server/utils/updates/siteLayout.js +200 -0
  62. package/server/utils/updates/starterFiles.js +118 -0
@@ -0,0 +1,109 @@
1
+ import { rmSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { compareVersions } from './helpers.js';
4
+ import { PACKAGE_ROOT, RUNNING_VERSION } from './runtime.js';
5
+ import extensionTargets, { extensionPlan } from './extensionTargets.js';
6
+ import fetchManifest from './fetchManifest.js';
7
+ import getInstallInfo from './getInstallInfo.js';
8
+ import { installedVersion, npmFailure, runNpm, targetsFor } from './npm.js';
9
+ import { paths, stage, writeState } from './siteLayout.js';
10
+ import { forcedFiles, planStarterFiles } from './starterFiles.js';
11
+ import { pendingUpgrades } from '../setup/runUpgrades.js';
12
+
13
+ let preparing = false;
14
+
15
+ const NO_FILES = { add: [], update: [], current: [], edited: [], deleted: [], forced: [] };
16
+
17
+ /*
18
+ What switching to a staged version would change besides the code, worked out from the staged copy: for kempo, the starter
19
+ files it would replace, add, keep (edited) or need confirmation for, and the data migrations it carries; for each updated
20
+ extension, what it adds and whether it is breaking. `breaking` is true when anything in it can only be undone from a backup.
21
+ */
22
+ export const buildPlan = async ({ cwd, oldRoot, newRoot, extensions = [], executor }) => {
23
+ const files = newRoot ? planStarterFiles({ cwd, oldRoot, newRoot, forced: forcedFiles(newRoot) }) : NO_FILES;
24
+ const upgrades = newRoot ? await pendingUpgrades({ dir: join(newRoot, 'server', 'db', 'upgrades'), ...(executor ? { executor } : {}) }) : [];
25
+ return { files, upgrades, extensions, breaking: upgrades.some(upgrade => upgrade.breaking) || extensions.some(extension => extension.breaking) };
26
+ };
27
+
28
+ /*
29
+ Installs newer versions of kempo and/or of installed extensions into a staging folder next to the running site, which keeps
30
+ running from its own files, and works out what switching to them would change. Nothing the site uses is touched, so it
31
+ can fail, or be abandoned, without a trace. Applying it is applyStagedUpdate. Only for a site that runs from a Docker
32
+ volume (KEMPO_SITE_ROOT); any other site is updated in place, by applyUpdate.
33
+
34
+ `version` is the kempo version, "latest" by default, or null to leave kempo as it is; `extensions` are names. All are
35
+ installed in one npm run so they are consistent with each other. Resolves to [error, { version, packages, plan }]. `run`,
36
+ `install`, `oldRoot`, `fetcher` and `executor` are for tests.
37
+ */
38
+ export default async ({ version = 'latest', extensions = [], siteRoot = process.env.KEMPO_SITE_ROOT, cwd = process.cwd(), oldRoot = PACKAGE_ROOT, install = getInstallInfo({ cwd }), run = runNpm, fetcher, executor } = {}) => {
39
+ if(!siteRoot){
40
+ return [{ code: 409, msg: 'This site is not set up to prepare an update ahead of switching to it' }, null];
41
+ }
42
+ if(!install.canUpdate){
43
+ return [{ code: 409, msg: install.reason }, null];
44
+ }
45
+ if(version === null && !extensions.length){
46
+ return [{ code: 400, msg: 'Choose something to update' }, null];
47
+ }
48
+ if(preparing){
49
+ return [{ code: 409, msg: 'An update is already being prepared' }, null];
50
+ }
51
+
52
+ preparing = true;
53
+ try {
54
+ let manifest = null;
55
+ if(version !== null){
56
+ const [manifestError, found] = await fetchManifest({ version, fetcher });
57
+ if(manifestError) return [manifestError, null];
58
+ if(compareVersions(found.version, RUNNING_VERSION) <= 0){
59
+ return [{ code: 409, msg: `Version ${RUNNING_VERSION} is already running` }, null];
60
+ }
61
+ manifest = found;
62
+ }
63
+
64
+ const kempoVersion = manifest?.version ?? RUNNING_VERSION;
65
+ const [targetsError, wanted] = await extensionTargets({ names: extensions, kempoVersion, root: cwd, fetcher, ...(executor ? { executor } : {}) });
66
+ if(targetsError) return [targetsError, null];
67
+
68
+ let staged;
69
+ try {
70
+ staged = stage({ siteRoot, version: kempoVersion });
71
+ } catch(error) {
72
+ return [{ code: 500, msg: `Space for the new version could not be made: ${error.message}` }, null];
73
+ }
74
+
75
+ const discard = () => rmSync(paths(siteRoot).staging, { recursive: true, force: true });
76
+ const targets = [...(manifest ? targetsFor({ manifest, dir: staged.dir }) : []), ...wanted.targets];
77
+ try {
78
+ await run(targets, staged.dir);
79
+ } catch(error) {
80
+ discard();
81
+ return [{ code: 500, msg: `The update could not be downloaded: ${npmFailure(error)}` }, null];
82
+ }
83
+
84
+ const missing = [...(manifest ? [{ name: 'kempo', to: manifest.version }] : []), ...wanted.items].find(item => installedVersion(staged.dir, item.name) !== item.to);
85
+ if(missing){
86
+ discard();
87
+ return [{ code: 500, msg: `npm finished but ${missing.name} ${missing.to} is not in the staged copy` }, null];
88
+ }
89
+
90
+ const [planError, extensionEntries] = await extensionPlan({ dir: staged.dir, items: wanted.items, executor });
91
+ if(planError){
92
+ discard();
93
+ return [planError, null];
94
+ }
95
+
96
+ let plan;
97
+ try {
98
+ plan = await buildPlan({ cwd, oldRoot, newRoot: manifest ? join(staged.dir, 'node_modules', 'kempo') : null, extensions: extensionEntries, executor });
99
+ } catch(error) {
100
+ discard();
101
+ return [{ code: 500, msg: `What this update would change could not be worked out: ${error.message}` }, null];
102
+ }
103
+
104
+ writeState(siteRoot, 'staged', { id: staged.id, dir: staged.dir, version: kempoVersion, kempoUpdate: Boolean(manifest), from: RUNNING_VERSION, packages: targets, plan, preparedAt: new Date().toISOString() });
105
+ return [null, { version: kempoVersion, kempoUpdate: Boolean(manifest), packages: targets, plan }];
106
+ } finally {
107
+ preparing = false;
108
+ }
109
+ };
@@ -0,0 +1,23 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { getCurrent, paths, readState } from './siteLayout.js';
4
+
5
+ /*
6
+ The part of the update status that only a site running from a Docker volume has: whether a version is staged and what
7
+ switching to it would change, whether there is an earlier version to go back to, and how the last switch ended.
8
+ */
9
+ export default (siteRoot = process.env.KEMPO_SITE_ROOT) => {
10
+ if(!siteRoot) return { mode: 'in-place' };
11
+
12
+ const staged = readState(siteRoot, 'staged');
13
+ const { previous } = getCurrent(siteRoot);
14
+ const last = readState(siteRoot, 'last');
15
+ return {
16
+ mode: 'release',
17
+ staged: staged && existsSync(join(staged.dir, 'node_modules')) ? { version: staged.version, kempoUpdate: Boolean(staged.kempoUpdate), packages: staged.packages ?? [], plan: staged.plan, preparedAt: staged.preparedAt } : null,
18
+ canGoBack: Boolean(previous?.id && existsSync(join(paths(siteRoot).releases, previous.id, 'node_modules'))),
19
+ previousVersion: previous?.version ?? null,
20
+ switching: readState(siteRoot, 'switch')?.status ?? null,
21
+ lastUpdate: last ? { ok: last.ok, from: last.from, to: last.to, packages: last.packages ?? [], reason: last.reason ?? null, breaking: Boolean(last.breaking), at: last.at } : null
22
+ };
23
+ };
@@ -0,0 +1,64 @@
1
+ import { spawn } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+
4
+ /*
5
+ How the server comes back after an update.
6
+
7
+ respawn this process starts its own replacement and exits. For `npm start` or `npm run dev` run by hand.
8
+ exit this process only exits and whatever started it starts it again (pm2, systemd, a container's
9
+ restart policy). Respawning there would fight the supervisor for the port, and a container
10
+ stops when its main process does.
11
+
12
+ Chosen by the KEMPO_RESTART environment variable ("respawn" or "exit"), otherwise "exit" when pm2, systemd,
13
+ Kubernetes or Docker is detected and "respawn" when none is.
14
+ */
15
+ export const restartMode = (env = process.env, inContainer = () => existsSync('/.dockerenv')) => {
16
+ if(env.KEMPO_RESTART === 'exit' || env.KEMPO_RESTART === 'respawn') return env.KEMPO_RESTART;
17
+ return env.pm_id !== undefined || env.INVOCATION_ID || env.KUBERNETES_SERVICE_HOST || inContainer() ? 'exit' : 'respawn';
18
+ };
19
+
20
+ /*
21
+ The replacement cannot be started by the process it replaces: the port is still taken. A small detached
22
+ watcher waits for that process to be gone and then starts the same command in the same directory with
23
+ the same environment. It gives up waiting after a minute and starts anyway.
24
+ */
25
+ const WATCHER = `
26
+ const { spawn } = require('child_process');
27
+ const pid = Number(process.argv[1]);
28
+ const command = JSON.parse(process.argv[2]);
29
+ const started = Date.now();
30
+ const alive = () => {
31
+ try { process.kill(pid, 0); return true; } catch(error) { return error.code === 'EPERM'; }
32
+ };
33
+ const wait = () => {
34
+ if(alive() && Date.now() - started < 60000) return setTimeout(wait, 200);
35
+ setTimeout(() => {
36
+ spawn(command[0], command.slice(1), { detached: true, stdio: 'ignore', cwd: process.cwd(), env: process.env, windowsHide: true }).unref();
37
+ process.exit(0);
38
+ }, 500);
39
+ };
40
+ wait();
41
+ `;
42
+
43
+ /*
44
+ Under a supervisor the exit code is nonzero on purpose (75, "try again later"): a Docker `restart: on-failure` or a
45
+ systemd `Restart=on-failure` policy restarts only after a failure, while `always` and `unless-stopped` restart after any exit.
46
+ */
47
+ export const RESTART_EXIT_CODE = 75;
48
+
49
+ export const restartServer = ({ mode = restartMode(), exit = code => process.exit(code), spawner = spawn } = {}) => {
50
+ if(mode === 'respawn'){
51
+ const command = [process.execPath, ...process.execArgv, ...process.argv.slice(1)];
52
+ spawner(process.execPath, ['-e', WATCHER, String(process.pid), JSON.stringify(command)], {
53
+ detached: true,
54
+ stdio: 'ignore',
55
+ cwd: process.cwd(),
56
+ env: process.env,
57
+ windowsHide: true
58
+ }).unref();
59
+ }
60
+ exit(mode === 'respawn' ? 0 : RESTART_EXIT_CODE);
61
+ };
62
+
63
+ /* Restarts after `delayMs`, so the response that announced it has been sent first. */
64
+ export const scheduleRestart = ({ delayMs = 1000, ...options } = {}) => setTimeout(() => restartServer(options), delayMs);
@@ -0,0 +1,56 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { RUNNING_VERSION } from './runtime.js';
4
+ import { restartMode, scheduleRestart } from './restart.js';
5
+ import { getCurrent, paths, readState, requestSwitch } from './siteLayout.js';
6
+
7
+ let going = false;
8
+
9
+ /*
10
+ Puts a site that runs from a Docker volume back on the version it had before the last update: the code, and the
11
+ starter files the update replaced. If that update changed data, going back also restores the database from the backup
12
+ taken before it, which loses whatever was entered since, so that needs `confirm.data`. Otherwise (the usual case: an
13
+ update only added to the database) the earlier version runs on the database as it is and nothing is lost.
14
+
15
+ Resolves to [error, { from, to, restart, restoresData }].
16
+ */
17
+ export default async ({ confirm = {}, siteRoot = process.env.KEMPO_SITE_ROOT, restart = scheduleRestart } = {}) => {
18
+ if(!siteRoot){
19
+ return [{ code: 409, msg: 'This site is not set up to go back to an earlier version' }, null];
20
+ }
21
+ if(going){
22
+ return [{ code: 409, msg: 'A switch is already under way' }, null];
23
+ }
24
+ if(readState(siteRoot, 'switch')){
25
+ return [{ code: 409, msg: 'The last switch has not finished proving itself yet' }, null];
26
+ }
27
+
28
+ const { previous, version } = getCurrent(siteRoot);
29
+ const source = previous?.id ? join(paths(siteRoot).releases, previous.id) : null;
30
+ if(!source || !existsSync(join(source, 'node_modules'))){
31
+ return [{ code: 409, msg: 'There is no earlier version to go back to' }, null];
32
+ }
33
+
34
+ const last = readState(siteRoot, 'last');
35
+ const restoresData = Boolean(last?.ok && last.breaking && last.to === version && last.dbBackup && existsSync(last.dbBackup));
36
+ if(restoresData && !confirm.data){
37
+ return [{ code: 409, msg: 'Going back restores the database from before the update and loses what was entered since', details: { confirm: 'data' } }, null];
38
+ }
39
+
40
+ going = true;
41
+ try {
42
+ requestSwitch({
43
+ siteRoot,
44
+ id: previous.id,
45
+ version: previous.version,
46
+ source,
47
+ kind: 'rollback',
48
+ filesBackup: last?.ok && last.to === version ? last.filesBackup : null,
49
+ dbBackup: restoresData ? last.dbBackup : null
50
+ });
51
+ restart();
52
+ return [null, { from: RUNNING_VERSION, to: previous.version, restart: restartMode(), restoresData }];
53
+ } finally {
54
+ going = false;
55
+ }
56
+ };
@@ -0,0 +1,52 @@
1
+ import { createHash } from 'crypto';
2
+ import { and, eq } from 'drizzle-orm';
3
+ import db from '../../db/index.js';
4
+ import { notification } from '../../db/schema.js';
5
+ import getSetting from '../settings/getSetting.js';
6
+ import createNotification from '../notifications/createNotification.js';
7
+ import getUpdateStatus from './getUpdateStatus.js';
8
+ import { PACKAGE } from './helpers.js';
9
+
10
+ /*
11
+ Raising a notification again would mark it unread for everyone, so each one has a key that names what it is about, and
12
+ one that already exists is left alone: one for each new version of kempo, and one for each distinct set of extension
13
+ updates that are waiting.
14
+ */
15
+ const raiseOnce = async ({ dedupeKey, ...notice }) => {
16
+ const [existing] = await db.select({ id: notification.id }).from(notification).where(and(eq(notification.owner, PACKAGE), eq(notification.dedupeKey, dedupeKey))).limit(1);
17
+ if(existing) return;
18
+ const [error] = await createNotification({ owner: PACKAGE, permission: 'system:updates:manage', dedupeKey, actions: [{ label: 'View updates', href: '/admin/updates' }], ...notice });
19
+ if(error) console.error(`[kempo] Update notification: ${error.msg}`);
20
+ };
21
+
22
+ /*
23
+ The scheduled check (kempo's `update-check` job). Asks npm for the newest version of kempo and of every installed extension
24
+ and tells everyone who can install them what is waiting, once for each new version of kempo and once for each distinct set
25
+ of extension updates. Nothing is said about a site that cannot install updates (a linked checkout). The
26
+ `system:update_check_enabled` setting turns this off.
27
+ */
28
+ export default async ({ fetcher, install } = {}) => {
29
+ const [, enabled] = await getSetting('system', 'update_check_enabled', true);
30
+ if(enabled === false) return;
31
+
32
+ const [, status] = await getUpdateStatus({ force: true, fetcher, install });
33
+ if(!status?.canUpdate) return;
34
+
35
+ if(status.updateAvailable){
36
+ await raiseOnce({
37
+ dedupeKey: `update:${status.latestVersion}`,
38
+ title: `Kempo ${status.latestVersion} is available`,
39
+ message: `This site runs ${status.runningVersion}.${status.major ? ' This is a major release and may include breaking changes.' : ''}`
40
+ });
41
+ }
42
+
43
+ const { updates } = status.extensionUpdates;
44
+ if(updates.length){
45
+ const list = updates.map(update => `${update.name}@${update.latestVersion}`);
46
+ await raiseOnce({
47
+ dedupeKey: `extension-updates:${createHash('sha256').update(list.join('\n')).digest('hex').slice(0, 16)}`,
48
+ title: updates.length === 1 ? `${updates[0].name} ${updates[0].latestVersion} is available` : `${updates.length} extension updates are available`,
49
+ message: `${updates.slice(0, 5).map(update => `${update.name} ${update.installedVersion} -> ${update.latestVersion}${update.major ? ' (major)' : ''}`).join(', ')}${updates.length > 5 ? ', and more' : ''}.`
50
+ });
51
+ }
52
+ };
@@ -0,0 +1,15 @@
1
+ import { readFileSync } from 'fs';
2
+ import { randomUUID } from 'crypto';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ /*
7
+ What this process is running, read once when it starts. After an update the files on disk are the new
8
+ version while this process is still the old one, so these are what tell the two apart: the running
9
+ version, and a boot id that is different in every process.
10
+ */
11
+ export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
12
+
13
+ export const RUNNING_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
14
+
15
+ export const BOOT_ID = randomUUID();
@@ -0,0 +1,200 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+
4
+ /*
5
+ The folders of a site that runs from a Docker volume (KEMPO_SITE_ROOT, normally /site), and the steps that move between
6
+ versions of kempo. It uses nothing but Node's own modules, because the image's startup script runs it before any of the
7
+ site's code can be trusted, and so a copy of it that is older than the site's still understands the state it left.
8
+
9
+ app/ the site. Its files are the owner's (public, templates, uploads, .env) and are never moved. Its
10
+ node_modules and package.json are "the code": the only part a version change replaces
11
+ staging/ a new version being installed, with its own node_modules and copy of the manifests, while the site
12
+ keeps running from app/
13
+ releases/ the code the site had before the last switch, kept so it can go back
14
+ state/ what the startup script and the site tell each other (see below)
15
+
16
+ A switch is two renames inside one volume, done by the startup script while no site process is running, so the site never
17
+ loads files from two versions at once. Each step can be repeated, so a switch that was cut short finishes at the next start.
18
+
19
+ state/switch.json says where a switch is: "requested" (the site asked, nothing moved yet), "switching" (moving),
20
+ "switched" (the new code is in place and has not yet proved itself) and is removed once the site has stayed up for a
21
+ minute. A site that stops while it is "switched" more than MAX_ATTEMPTS times, or says it failed, is put back.
22
+ */
23
+ export const MAX_ATTEMPTS = 3;
24
+
25
+ const MANIFESTS = ['package.json', 'package-lock.json'];
26
+
27
+ export const paths = siteRoot => ({
28
+ siteRoot,
29
+ app: join(siteRoot, 'app'),
30
+ staging: join(siteRoot, 'staging'),
31
+ releases: join(siteRoot, 'releases'),
32
+ state: join(siteRoot, 'state'),
33
+ });
34
+
35
+ /*
36
+ State files
37
+ */
38
+ const readJson = file => {
39
+ try {
40
+ return JSON.parse(readFileSync(file, 'utf8'));
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ const writeJson = (file, data) => {
47
+ mkdirSync(dirname(file), { recursive: true });
48
+ writeFileSync(`${file}.tmp`, JSON.stringify(data, null, 2));
49
+ renameSync(`${file}.tmp`, file);
50
+ };
51
+
52
+ export const readState = (siteRoot, name) => readJson(join(paths(siteRoot).state, `${name}.json`));
53
+
54
+ export const writeState = (siteRoot, name, data) => writeJson(join(paths(siteRoot).state, `${name}.json`), data);
55
+
56
+ export const clearState = (siteRoot, name) => rmSync(join(paths(siteRoot).state, `${name}.json`), { force: true });
57
+
58
+ const versionIn = dir => readJson(join(dir, 'node_modules', 'kempo', 'package.json'))?.version ?? null;
59
+
60
+ const releaseId = version => `${Date.now()}-${version}`;
61
+
62
+ const copyManifests = (from, to) => {
63
+ for(const name of MANIFESTS){
64
+ if(existsSync(join(from, name))) cpSync(join(from, name), join(to, name));
65
+ }
66
+ };
67
+
68
+ /*
69
+ The first start: the image holds the site as it was built (its files and its node_modules) and the volume is empty,
70
+ so the volume is filled from it. After that the volume is the site and the image is only the runtime.
71
+ */
72
+ export const seedSite = ({ siteRoot, seed }) => {
73
+ const { app } = paths(siteRoot);
74
+ if(existsSync(join(app, 'package.json'))) return false;
75
+ mkdirSync(app, { recursive: true });
76
+ cpSync(seed, app, { recursive: true, verbatimSymlinks: true });
77
+ const version = versionIn(app);
78
+ writeState(siteRoot, 'current', { id: releaseId(version ?? 'seed'), version, previous: null });
79
+ return true;
80
+ };
81
+
82
+ export const getCurrent = siteRoot => readState(siteRoot, 'current') ?? { id: null, version: versionIn(paths(siteRoot).app), previous: null };
83
+
84
+ /*
85
+ Staging a new version: a copy of the code the site has now, which the caller then installs into. The running site is
86
+ not touched. Only one is kept: staging again replaces the one before.
87
+ */
88
+ export const stage = ({ siteRoot, version }) => {
89
+ const { app, staging } = paths(siteRoot);
90
+ rmSync(staging, { recursive: true, force: true });
91
+ const id = releaseId(version);
92
+ const dir = join(staging, id);
93
+ mkdirSync(dir, { recursive: true });
94
+ copyManifests(app, dir);
95
+ cpSync(join(app, 'node_modules'), join(dir, 'node_modules'), { recursive: true, verbatimSymlinks: true });
96
+ return { id, dir };
97
+ };
98
+
99
+ /*
100
+ Asking for a switch. `source` is a folder holding node_modules and the manifests: a staged version, or an earlier
101
+ release to go back to. The startup script does it at the next start.
102
+ */
103
+ export const requestSwitch = ({ siteRoot, id, version, source, kind = 'update', ...details }) => {
104
+ const current = getCurrent(siteRoot);
105
+ writeState(siteRoot, 'switch', { status: 'requested', kind, id, version, source, from: { id: current.id, version: current.version }, attempts: 0, requestedAt: new Date().toISOString(), ...details });
106
+ };
107
+
108
+ const swapIn = ({ siteRoot, sw }) => {
109
+ const { app, releases } = paths(siteRoot);
110
+ const old = join(releases, sw.from.id ?? `${Date.now()}-unknown`);
111
+ const oldModules = join(app, 'node_modules');
112
+
113
+ if(existsSync(oldModules) && !existsSync(join(old, 'node_modules'))){
114
+ mkdirSync(old, { recursive: true });
115
+ renameSync(oldModules, join(old, 'node_modules'));
116
+ copyManifests(app, old);
117
+ }
118
+ if(existsSync(join(sw.source, 'node_modules'))){
119
+ renameSync(join(sw.source, 'node_modules'), oldModules);
120
+ copyManifests(sw.source, app);
121
+ }
122
+ rmSync(sw.source, { recursive: true, force: true });
123
+ rmSync(paths(siteRoot).staging, { recursive: true, force: true });
124
+ writeState(siteRoot, 'current', { id: sw.id, version: sw.version, previous: { id: sw.from.id, version: sw.from.version } });
125
+ };
126
+
127
+ /* Carries out a requested (or half-done) switch. Returns the switch, or null when there was nothing to do. */
128
+ export const performSwitch = siteRoot => {
129
+ const sw = readState(siteRoot, 'switch');
130
+ if(!sw || !['requested', 'switching'].includes(sw.status)) return null;
131
+ writeState(siteRoot, 'switch', { ...sw, status: 'switching' });
132
+ swapIn({ siteRoot, sw });
133
+ const done = { ...sw, status: 'switched', attempts: 0 };
134
+ writeState(siteRoot, 'switch', done);
135
+ return done;
136
+ };
137
+
138
+ /*
139
+ Files the update changed outside the code (starter files), saved before they were overwritten, put back: those that
140
+ existed are restored and those the update created are removed.
141
+ */
142
+ export const restoreFiles = ({ siteRoot, backup }) => {
143
+ if(!backup?.dir) return;
144
+ const { app } = paths(siteRoot);
145
+ for(const { path, existed } of backup.files ?? []){
146
+ if(existed) cpSync(join(backup.dir, path), join(app, path), { force: true });
147
+ else rmSync(join(app, path), { force: true });
148
+ }
149
+ };
150
+
151
+ /*
152
+ Back to the release before this one. Used by the startup script when a new version keeps failing, and for going back by
153
+ hand. Records why in state/last.json.
154
+ */
155
+ export const rollBack = ({ siteRoot, reason }) => {
156
+ const sw = readState(siteRoot, 'switch');
157
+ const current = getCurrent(siteRoot);
158
+ const previous = current.previous;
159
+ const source = previous?.id ? join(paths(siteRoot).releases, previous.id) : null;
160
+ if(!source || !existsSync(join(source, 'node_modules'))) return { ok: false, reason: 'There is no earlier version to go back to' };
161
+
162
+ swapIn({ siteRoot, sw: { id: previous.id, version: previous.version, source, from: { id: current.id, version: current.version } } });
163
+ restoreFiles({ siteRoot, backup: sw?.filesBackup });
164
+ writeState(siteRoot, 'last', { ok: false, from: current.version, to: previous.version, reason, at: new Date().toISOString(), dbBackup: sw?.dbBackup ?? null });
165
+ clearState(siteRoot, 'switch');
166
+ return { ok: true, from: current.version, to: previous.version, dbBackup: sw?.dbBackup ?? null };
167
+ };
168
+
169
+ /* True when a switch has not proved itself and the site has failed to stay up. Going back by hand is never undone by itself. */
170
+ export const shouldRollBack = sw => sw?.status === 'switched' && sw.kind !== 'rollback' && (Boolean(sw.failed) || sw.attempts > MAX_ATTEMPTS);
171
+
172
+ export const countAttempt = siteRoot => {
173
+ const sw = readState(siteRoot, 'switch');
174
+ if(sw?.status !== 'switched') return sw;
175
+ const next = { ...sw, attempts: sw.attempts + 1 };
176
+ writeState(siteRoot, 'switch', next);
177
+ return next;
178
+ };
179
+
180
+ /* The site says its new code cannot work (a data migration failed). It is put back at the next start. */
181
+ export const flagFailed = ({ siteRoot, reason }) => {
182
+ const sw = readState(siteRoot, 'switch');
183
+ if(sw?.status === 'switched') writeState(siteRoot, 'switch', { ...sw, failed: true, failure: reason });
184
+ };
185
+
186
+ /* The new code has stayed up: the switch is final, and every release but the one before this is deleted. */
187
+ export const markHealthy = siteRoot => {
188
+ const sw = readState(siteRoot, 'switch');
189
+ if(sw?.status !== 'switched') return false;
190
+ const { releases } = paths(siteRoot);
191
+ const current = getCurrent(siteRoot);
192
+ writeState(siteRoot, 'last', { ok: true, from: sw.from.version, to: sw.version, kind: sw.kind, breaking: Boolean(sw.breaking), packages: sw.packages ?? [], dbBackup: sw.dbBackup ?? null, filesBackup: sw.filesBackup ?? null, at: new Date().toISOString() });
193
+ clearState(siteRoot, 'switch');
194
+ if(existsSync(releases)){
195
+ for(const name of readdirSync(releases)){
196
+ if(name !== current.previous?.id) rmSync(join(releases, name), { recursive: true, force: true });
197
+ }
198
+ }
199
+ return true;
200
+ };
@@ -0,0 +1,118 @@
1
+ import { createHash } from 'crypto';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
3
+ import { dirname, join, relative, sep } from 'path';
4
+
5
+ /*
6
+ The starter files: what `npx kempo init` copies from the kempo package into a site (app-public/ into public/, and
7
+ app-templates/ into templates/). They start as kempo's and become the owner's the moment anyone edits one.
8
+
9
+ Kempo remembers the fingerprint (SHA-256) of each starter file it put in the site, in .kempo/shipped.json. An update then
10
+ treats a file in the site like this:
11
+
12
+ identical to the new version's nothing to do
13
+ still what kempo put there never edited: replaced by the new version
14
+ anything else edited, by anyone (the admin, FTP, a shell): kept, and offered as "newer" only
15
+ missing, but kempo shipped it before the owner deleted it: it stays deleted
16
+ missing and new in this version added
17
+
18
+ A site made before this record existed has none, so a file is judged against what the version that is running ships.
19
+ A version can name files it must replace even when they were edited (a security fix) in its package.json:
20
+ { "kempo": { "forceUpdate": ["public/login/", "public/init.js"] } }. Those wait for the owner's confirmation, and the
21
+ file as it was is saved first.
22
+ */
23
+ export const SOURCES = [['app-public', 'public'], ['app-templates', 'templates']];
24
+
25
+ const RECORD = ['.kempo', 'shipped.json'];
26
+
27
+ const hashOf = file => createHash('sha256').update(readFileSync(file)).digest('hex');
28
+
29
+ const walk = dir => readdirSync(dir, { withFileTypes: true }).flatMap(entry => (entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)]));
30
+
31
+ /* [{ path, source, hash }] for every starter file a kempo package ships, `path` being where it goes in the site. */
32
+ export const listShipped = packageRoot => SOURCES.flatMap(([from, to]) => {
33
+ const base = join(packageRoot, from);
34
+ if(!existsSync(base)) return [];
35
+ return walk(base).map(file => ({ path: `${to}/${relative(base, file).split(sep).join('/')}`, source: file, hash: hashOf(file) }));
36
+ });
37
+
38
+ export const readRecord = cwd => {
39
+ try {
40
+ return JSON.parse(readFileSync(join(cwd, ...RECORD), 'utf8'));
41
+ } catch {
42
+ return { version: null, files: {} };
43
+ }
44
+ };
45
+
46
+ const writeRecord = (cwd, record) => {
47
+ mkdirSync(join(cwd, '.kempo'), { recursive: true });
48
+ writeFileSync(join(cwd, ...RECORD), JSON.stringify(record, null, 2));
49
+ };
50
+
51
+ /* The files a version must replace, from its package.json. */
52
+ export const forcedFiles = packageRoot => {
53
+ try {
54
+ return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).kempo?.forceUpdate ?? [];
55
+ } catch {
56
+ return [];
57
+ }
58
+ };
59
+
60
+ /* For `npx kempo init`: remembers what was copied. */
61
+ export const recordShipped = ({ cwd, packageRoot, version }) => {
62
+ const files = {};
63
+ for(const file of listShipped(packageRoot)){
64
+ if(existsSync(join(cwd, file.path)) && hashOf(join(cwd, file.path)) === file.hash) files[file.path] = file.hash;
65
+ }
66
+ writeRecord(cwd, { version, files });
67
+ };
68
+
69
+ /* What an update would do to the site's starter files. Changes nothing. */
70
+ export const planStarterFiles = ({ cwd, oldRoot, newRoot, forced = forcedFiles(newRoot) }) => {
71
+ const recorded = readRecord(cwd).files;
72
+ const before = new Map(listShipped(oldRoot).map(file => [file.path, file.hash]));
73
+ const plan = { add: [], update: [], current: [], edited: [], deleted: [], forced: [] };
74
+
75
+ for(const file of listShipped(newRoot)){
76
+ const target = join(cwd, file.path);
77
+ if(!existsSync(target)){
78
+ (recorded[file.path] || before.has(file.path) ? plan.deleted : plan.add).push(file.path);
79
+ } else if(before.get(file.path) === file.hash || hashOf(target) === file.hash){
80
+ plan.current.push(file.path);
81
+ } else {
82
+ const shipped = recorded[file.path] ?? before.get(file.path);
83
+ if(shipped && hashOf(target) === shipped) plan.update.push(file.path);
84
+ else if(forced.some(entry => entry === file.path || (entry.endsWith('/') && file.path.startsWith(entry)))) plan.forced.push(file.path);
85
+ else plan.edited.push(file.path);
86
+ }
87
+ }
88
+ return plan;
89
+ };
90
+
91
+ /*
92
+ Does it. Every file it replaces is copied to `backupDir` first. Resolves to { dir, files: [{ path, existed }] }, which is
93
+ what putting them back needs (restoreFiles in siteLayout.js).
94
+ */
95
+ export const applyStarterFiles = ({ cwd, newRoot, version, plan, includeForced = false, backupDir }) => {
96
+ const shipped = new Map(listShipped(newRoot).map(file => [file.path, file]));
97
+ const changed = [...plan.add, ...plan.update, ...(includeForced ? plan.forced : [])];
98
+ const files = [];
99
+
100
+ for(const path of changed){
101
+ const target = join(cwd, path);
102
+ const existed = existsSync(target);
103
+ if(existed){
104
+ mkdirSync(dirname(join(backupDir, path)), { recursive: true });
105
+ cpSync(target, join(backupDir, path));
106
+ }
107
+ mkdirSync(dirname(target), { recursive: true });
108
+ cpSync(shipped.get(path).source, target);
109
+ files.push({ path, existed });
110
+ }
111
+
112
+ const record = readRecord(cwd);
113
+ for(const path of [...changed, ...plan.current]){
114
+ if(existsSync(join(cwd, path)) && hashOf(join(cwd, path)) === shipped.get(path).hash) record.files[path] = shipped.get(path).hash;
115
+ }
116
+ writeRecord(cwd, { version, files: record.files });
117
+ return { dir: backupDir, files };
118
+ };