@materializecms/cli 0.0.0-stage → 0.1.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +20 -2
- package/compose.yaml +35 -0
- package/install.sh +437 -0
- package/package.json +49 -4
- package/src/cli.d.ts +2 -0
- package/src/cli.js +15 -0
- package/src/commands.d.ts +1 -0
- package/src/commands.js +365 -0
- package/src/load-config.d.ts +13 -0
- package/src/load-config.js +53 -0
- package/src/site.d.ts +38 -0
- package/src/site.js +193 -0
- package/src/supervisor.d.ts +29 -0
- package/src/supervisor.js +169 -0
- package/src/updates.d.ts +14 -0
- package/src/updates.js +169 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Exit code with which the server asks to be started again (and to apply a pending swap). */
|
|
2
|
+
export declare const RESTART = 75;
|
|
3
|
+
export declare const updatesDir: (site: string) => string;
|
|
4
|
+
/** Written by the server before it exits with RESTART. */
|
|
5
|
+
export interface Pending {
|
|
6
|
+
kind: 'install' | 'rollback';
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
at: string;
|
|
10
|
+
}
|
|
11
|
+
/** What the last swap did, read by the panel after the restart. */
|
|
12
|
+
export interface SwapResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
kind: Pending['kind'];
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
at: string;
|
|
18
|
+
message?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Exchanges node_modules and the package files of the site with those kept in `other`. */
|
|
21
|
+
export declare function exchange(site: string, other: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Applies a pending swap. An install moves the new version in and keeps the current one as
|
|
24
|
+
* "previous"; a rollback exchanges current and previous, so it can itself be undone.
|
|
25
|
+
*/
|
|
26
|
+
export declare function applyPending(site: string, pending: Pending): Promise<void>;
|
|
27
|
+
/** Undoes `applyPending` after a failed start. */
|
|
28
|
+
export declare function revertPending(site: string, pending: Pending): Promise<void>;
|
|
29
|
+
export declare function supervise(script: string, args: string[], site?: string): Promise<never>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `materialize start` runs the server as a child of this small process, which keeps it alive:
|
|
3
|
+
* it restarts the server if it stops unexpectedly, and swaps the installed version when the
|
|
4
|
+
* panel asks for an update (or to go back to the previous one).
|
|
5
|
+
*
|
|
6
|
+
* The swap: an update is installed beside the site in .materialize/updates/next, then the server
|
|
7
|
+
* exits with RESTART. Here the current node_modules moves to .materialize/updates/previous, the
|
|
8
|
+
* new one takes its place, and the server starts again. If it does not report ready in time,
|
|
9
|
+
* the old version comes back.
|
|
10
|
+
*
|
|
11
|
+
* Only Node's own modules here: this code stays loaded while the packages under it change.
|
|
12
|
+
*/
|
|
13
|
+
import { fork } from 'node:child_process';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
/** Exit code with which the server asks to be started again (and to apply a pending swap). */
|
|
18
|
+
export const RESTART = 75;
|
|
19
|
+
/** How long a freshly swapped version has to report ready before it is rolled back. */
|
|
20
|
+
const READY_TIMEOUT = 180_000;
|
|
21
|
+
export const updatesDir = (site) => join(site, '.materialize', 'updates');
|
|
22
|
+
const FILES = ['package.json', 'package-lock.json'];
|
|
23
|
+
/** Exchanges node_modules and the package files of the site with those kept in `other`. */
|
|
24
|
+
export async function exchange(site, other) {
|
|
25
|
+
const tmp = join(updatesDir(site), 'swap');
|
|
26
|
+
await rm(tmp, { recursive: true, force: true });
|
|
27
|
+
await mkdir(tmp, { recursive: true });
|
|
28
|
+
// site → tmp, other → site, tmp → other: each step is a rename on the same disk.
|
|
29
|
+
for (const name of ['node_modules', ...FILES]) {
|
|
30
|
+
if (existsSync(join(site, name)))
|
|
31
|
+
await rename(join(site, name), join(tmp, name));
|
|
32
|
+
}
|
|
33
|
+
for (const name of ['node_modules', ...FILES]) {
|
|
34
|
+
if (existsSync(join(other, name)))
|
|
35
|
+
await rename(join(other, name), join(site, name));
|
|
36
|
+
}
|
|
37
|
+
await rm(other, { recursive: true, force: true });
|
|
38
|
+
await rename(tmp, other);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Applies a pending swap. An install moves the new version in and keeps the current one as
|
|
42
|
+
* "previous"; a rollback exchanges current and previous, so it can itself be undone.
|
|
43
|
+
*/
|
|
44
|
+
export async function applyPending(site, pending) {
|
|
45
|
+
const dir = updatesDir(site);
|
|
46
|
+
if (pending.kind === 'install') {
|
|
47
|
+
const next = join(dir, 'next');
|
|
48
|
+
if (!existsSync(join(next, 'node_modules')))
|
|
49
|
+
throw new Error('La nuova versione non è stata scaricata');
|
|
50
|
+
await exchange(site, next);
|
|
51
|
+
await rm(join(dir, 'previous'), { recursive: true, force: true });
|
|
52
|
+
await rename(next, join(dir, 'previous'));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const previous = join(dir, 'previous');
|
|
56
|
+
if (!existsSync(join(previous, 'node_modules')))
|
|
57
|
+
throw new Error('Non c’è una versione precedente');
|
|
58
|
+
await exchange(site, previous);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Undoes `applyPending` after a failed start. */
|
|
62
|
+
export async function revertPending(site, pending) {
|
|
63
|
+
const dir = updatesDir(site);
|
|
64
|
+
// Either way the version that was running before sits in "previous" now.
|
|
65
|
+
await exchange(site, join(dir, 'previous'));
|
|
66
|
+
// A failed new version is not one to go back to.
|
|
67
|
+
if (pending.kind === 'install')
|
|
68
|
+
await rm(join(dir, 'previous'), { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
async function readJson(file) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function supervise(script, args, site = process.cwd()) {
|
|
79
|
+
const dir = updatesDir(site);
|
|
80
|
+
let child = null;
|
|
81
|
+
let stopping = false;
|
|
82
|
+
let failures = 0;
|
|
83
|
+
/** Starts the server; resolves with its exit code, and calls `onReady` when it reports ready. */
|
|
84
|
+
const start = (onReady, onFailed, update) => new Promise((resolve) => {
|
|
85
|
+
const proc = fork(script, ['start', ...args], {
|
|
86
|
+
stdio: 'inherit',
|
|
87
|
+
// A server started for an update says so in the panel until it is ready, then records the result.
|
|
88
|
+
env: { ...process.env, MATERIALIZE_SUPERVISED: '1', MATERIALIZE_UPDATE: update ? JSON.stringify(update) : '' },
|
|
89
|
+
});
|
|
90
|
+
child = proc;
|
|
91
|
+
proc.on('message', (message) => {
|
|
92
|
+
if (message === 'ready')
|
|
93
|
+
onReady?.();
|
|
94
|
+
else if (message === 'failed')
|
|
95
|
+
onFailed?.();
|
|
96
|
+
});
|
|
97
|
+
proc.on('exit', (code, signal) => {
|
|
98
|
+
child = null;
|
|
99
|
+
resolve(code ?? (signal ? 1 : 0));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
const stop = (signal) => {
|
|
103
|
+
stopping = true;
|
|
104
|
+
if (child)
|
|
105
|
+
child.kill(signal);
|
|
106
|
+
else
|
|
107
|
+
process.exit(0);
|
|
108
|
+
};
|
|
109
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
110
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
111
|
+
for (;;) {
|
|
112
|
+
const pendingFile = join(dir, 'pending.json');
|
|
113
|
+
const pending = await readJson(pendingFile);
|
|
114
|
+
let code;
|
|
115
|
+
if (pending) {
|
|
116
|
+
await rm(pendingFile, { force: true });
|
|
117
|
+
const result = { ok: false, kind: pending.kind, from: pending.from, to: pending.to, at: pending.at };
|
|
118
|
+
try {
|
|
119
|
+
await applyPending(site, pending);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
result.message = error instanceof Error ? error.message : String(error);
|
|
123
|
+
await writeFile(join(dir, 'result.json'), JSON.stringify(result));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
console.log(` Materialize ${pending.from} → ${pending.to}: avvio la ${pending.to}…`);
|
|
127
|
+
// The new version must report ready in time; otherwise the old one comes back.
|
|
128
|
+
let ready = false;
|
|
129
|
+
let timer;
|
|
130
|
+
const running = start(() => {
|
|
131
|
+
ready = true;
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}, () => {
|
|
134
|
+
result.message = 'Con la nuova versione il sito non si compila';
|
|
135
|
+
child?.kill('SIGTERM');
|
|
136
|
+
}, pending);
|
|
137
|
+
timer = setTimeout(() => {
|
|
138
|
+
result.message = 'La nuova versione non è partita in tempo';
|
|
139
|
+
child?.kill('SIGTERM');
|
|
140
|
+
}, READY_TIMEOUT);
|
|
141
|
+
code = await running;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
if (!ready && !stopping) {
|
|
144
|
+
result.message ??= `La nuova versione si è fermata all’avvio (codice ${code})`;
|
|
145
|
+
await revertPending(site, pending);
|
|
146
|
+
await writeFile(join(dir, 'result.json'), JSON.stringify(result));
|
|
147
|
+
console.log(` ✗ ${result.message}: torno alla ${pending.from}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const started = Date.now();
|
|
153
|
+
code = await start(() => {
|
|
154
|
+
failures = 0;
|
|
155
|
+
});
|
|
156
|
+
if (Date.now() - started > 60_000)
|
|
157
|
+
failures = 0;
|
|
158
|
+
}
|
|
159
|
+
if (stopping || code === 0)
|
|
160
|
+
process.exit(code);
|
|
161
|
+
if (code === RESTART)
|
|
162
|
+
continue;
|
|
163
|
+
// An unexpected stop: start again, waiting a little longer each time (up to half a minute).
|
|
164
|
+
failures++;
|
|
165
|
+
const wait = Math.min(30, 2 ** Math.min(failures, 5));
|
|
166
|
+
console.log(` ✗ Il server si è fermato (codice ${code}): riparto tra ${wait} s`);
|
|
167
|
+
await new Promise((r) => setTimeout(r, wait * 1000));
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/updates.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Updates } from '@materializecms/server';
|
|
2
|
+
/**
|
|
3
|
+
* Updates for a site run by `materialize start`: the new version is installed in
|
|
4
|
+
* .materialize/updates/next while the site keeps running; then the server exits and the
|
|
5
|
+
* supervisor swaps it in (see supervisor.ts).
|
|
6
|
+
*/
|
|
7
|
+
export declare function createUpdates(options: {
|
|
8
|
+
site: string;
|
|
9
|
+
mode: 'dev' | 'start';
|
|
10
|
+
/** Closes the server and exits with RESTART. */
|
|
11
|
+
restart: () => Promise<void>;
|
|
12
|
+
}): Updates & {
|
|
13
|
+
ready: () => Promise<void>;
|
|
14
|
+
};
|
package/src/updates.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants, existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { access, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { join, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { newer } from '@materializecms/server';
|
|
8
|
+
import { updatesDir } from './supervisor.js';
|
|
9
|
+
const CURRENT = createRequire(import.meta.url)('../package.json').version;
|
|
10
|
+
const CLI = '@materializecms/cli';
|
|
11
|
+
/** Runs the site's package manager; resolves with its output, rejects with the end of it. */
|
|
12
|
+
function npm(args, cwd, timeout = 600_000) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
|
|
15
|
+
cwd,
|
|
16
|
+
env: { ...process.env, npm_config_update_notifier: 'false', npm_config_fund: 'false' },
|
|
17
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
18
|
+
timeout,
|
|
19
|
+
});
|
|
20
|
+
let out = '';
|
|
21
|
+
child.stdout.on('data', (d) => (out += d));
|
|
22
|
+
child.stderr.on('data', (d) => (out += d));
|
|
23
|
+
child.on('error', (error) => reject(Object.assign(new Error(`npm non si avvia: ${error.message}`), { log: out.slice(-4000) })));
|
|
24
|
+
child.on('close', (code) => {
|
|
25
|
+
if (code === 0)
|
|
26
|
+
resolve(out);
|
|
27
|
+
else
|
|
28
|
+
reject(Object.assign(new Error(`npm si è fermato con un errore (codice ${code})`), { log: out.slice(-4000) }));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const readJson = async (file) => JSON.parse(await readFile(file, 'utf8').catch(() => 'null'));
|
|
33
|
+
/**
|
|
34
|
+
* Updates for a site run by `materialize start`: the new version is installed in
|
|
35
|
+
* .materialize/updates/next while the site keeps running; then the server exits and the
|
|
36
|
+
* supervisor swaps it in (see supervisor.ts).
|
|
37
|
+
*/
|
|
38
|
+
export function createUpdates(options) {
|
|
39
|
+
const { site } = options;
|
|
40
|
+
const dir = updatesDir(site);
|
|
41
|
+
// Started by the supervisor right after a swap: not done until the site is built on it.
|
|
42
|
+
let swapping = process.env.MATERIALIZE_UPDATE ? JSON.parse(process.env.MATERIALIZE_UPDATE) : null;
|
|
43
|
+
let latest = null;
|
|
44
|
+
let notes = [];
|
|
45
|
+
let checkedAt = null;
|
|
46
|
+
let checkError;
|
|
47
|
+
let job = { state: 'idle' };
|
|
48
|
+
const unavailable = async () => {
|
|
49
|
+
if (options.mode === 'dev')
|
|
50
|
+
return 'In sviluppo (materialize dev) si aggiorna da terminale: gli aggiornamenti dal pannello funzionano con materialize start.';
|
|
51
|
+
if (!process.env.MATERIALIZE_SUPERVISED)
|
|
52
|
+
return 'Il server è avviato senza il suo supervisore: aggiorna da terminale, oppure avvialo con materialize start.';
|
|
53
|
+
// Linked from a source checkout (like the example in the repository): git updates it.
|
|
54
|
+
const here = realpathSync(fileURLToPath(import.meta.url));
|
|
55
|
+
if (!here.startsWith(realpathSync(join(site, 'node_modules')) + sep))
|
|
56
|
+
return 'Materialize è collegato dal codice sorgente: si aggiorna con git.';
|
|
57
|
+
const pkg = await readJson(join(site, 'package.json'));
|
|
58
|
+
if (!pkg?.dependencies?.[CLI])
|
|
59
|
+
return `Il package.json del sito non ha ${CLI} tra le dipendenze.`;
|
|
60
|
+
try {
|
|
61
|
+
await access(site, constants.W_OK);
|
|
62
|
+
await access(join(site, 'node_modules'), constants.W_OK);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 'Il programma non può scrivere nella cartella del sito: aggiorna da terminale, con l’utente che la possiede.';
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
const previous = async () => {
|
|
70
|
+
const pkg = await readJson(join(dir, 'previous', 'package.json'));
|
|
71
|
+
return existsSync(join(dir, 'previous', 'node_modules')) ? (pkg?.dependencies?.[CLI] ?? null) : null;
|
|
72
|
+
};
|
|
73
|
+
const status = async () => ({
|
|
74
|
+
current: CURRENT,
|
|
75
|
+
latest,
|
|
76
|
+
notes,
|
|
77
|
+
checkedAt,
|
|
78
|
+
checkError,
|
|
79
|
+
unavailable: await unavailable(),
|
|
80
|
+
previous: await previous(),
|
|
81
|
+
job: swapping ? { state: 'restarting', step: 'Preparo il sito con la nuova versione' } : job,
|
|
82
|
+
last: await readJson(join(dir, 'result.json')),
|
|
83
|
+
});
|
|
84
|
+
const restartWith = async (pending) => {
|
|
85
|
+
await writeFile(join(dir, 'pending.json'), JSON.stringify(pending));
|
|
86
|
+
job = { state: 'restarting', step: 'Riavvio' };
|
|
87
|
+
// A moment for the panel to hear back before the server goes away.
|
|
88
|
+
setTimeout(() => void options.restart(), 300);
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
status,
|
|
92
|
+
/** The server is up and the site built: a swap that brought it here went well. */
|
|
93
|
+
async ready() {
|
|
94
|
+
if (!swapping)
|
|
95
|
+
return;
|
|
96
|
+
const result = { ok: true, ...swapping };
|
|
97
|
+
await writeFile(join(dir, 'result.json'), JSON.stringify(result));
|
|
98
|
+
swapping = null;
|
|
99
|
+
},
|
|
100
|
+
async check() {
|
|
101
|
+
try {
|
|
102
|
+
const out = await npm(['view', `${CLI}@latest`, 'version', 'changelog', '--json'], site, 30_000);
|
|
103
|
+
const info = JSON.parse(out);
|
|
104
|
+
latest = typeof info === 'string' ? info : (info.version ?? null);
|
|
105
|
+
notes = (typeof info === 'string' ? [] : (info.changelog ?? [])).filter((r) => newer(r.version, CURRENT));
|
|
106
|
+
checkError = undefined;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const log = error.log ?? '';
|
|
110
|
+
checkError = /E404|404 Not Found/.test(log)
|
|
111
|
+
? 'Nessuna versione pubblicata da controllare.'
|
|
112
|
+
: 'Non riesco a raggiungere il registro dei pacchetti: riprova più tardi.';
|
|
113
|
+
}
|
|
114
|
+
checkedAt = new Date().toISOString();
|
|
115
|
+
return status();
|
|
116
|
+
},
|
|
117
|
+
async install(version, backup) {
|
|
118
|
+
const reason = await unavailable();
|
|
119
|
+
if (reason)
|
|
120
|
+
throw new Error(reason);
|
|
121
|
+
if (job.state === 'working' || job.state === 'restarting')
|
|
122
|
+
throw new Error('C’è già un aggiornamento in corso');
|
|
123
|
+
if (!/^\d+\.\d+\.\d+$/.test(version))
|
|
124
|
+
throw new Error('Versione non valida');
|
|
125
|
+
job = { state: 'working', step: 'Backup del database e dei media' };
|
|
126
|
+
void (async () => {
|
|
127
|
+
try {
|
|
128
|
+
await backup();
|
|
129
|
+
job = { state: 'working', step: `Scarico Materialize ${version}` };
|
|
130
|
+
const next = join(dir, 'next');
|
|
131
|
+
await rm(next, { recursive: true, force: true });
|
|
132
|
+
await mkdir(next, { recursive: true });
|
|
133
|
+
// The site's own package files, with every Materialize package at the new version.
|
|
134
|
+
const pkg = JSON.parse(await readFile(join(site, 'package.json'), 'utf8'));
|
|
135
|
+
for (const name of Object.keys(pkg.dependencies ?? {}))
|
|
136
|
+
if (name.startsWith('@materializecms/'))
|
|
137
|
+
pkg.dependencies[name] = version;
|
|
138
|
+
await writeFile(join(next, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
139
|
+
for (const name of ['package-lock.json', '.npmrc'])
|
|
140
|
+
if (existsSync(join(site, name)))
|
|
141
|
+
await copyFile(join(site, name), join(next, name));
|
|
142
|
+
await npm(['install', '--no-audit', '--no-fund'], next);
|
|
143
|
+
job = { state: 'working', step: 'Controllo la nuova versione' };
|
|
144
|
+
const installed = await readJson(join(next, 'node_modules', CLI, 'package.json'));
|
|
145
|
+
if (installed?.version !== version)
|
|
146
|
+
throw new Error(`Installata la ${installed?.version ?? '?'} invece della ${version}`);
|
|
147
|
+
await restartWith({ kind: 'install', from: CURRENT, to: version, at: new Date().toISOString() });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
job = {
|
|
151
|
+
state: 'failed',
|
|
152
|
+
message: error instanceof Error ? error.message : String(error),
|
|
153
|
+
log: error.log,
|
|
154
|
+
};
|
|
155
|
+
await rm(join(dir, 'next'), { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
},
|
|
159
|
+
async rollback() {
|
|
160
|
+
const reason = await unavailable();
|
|
161
|
+
if (reason)
|
|
162
|
+
throw new Error(reason);
|
|
163
|
+
const to = await previous();
|
|
164
|
+
if (!to)
|
|
165
|
+
throw new Error('Non c’è una versione precedente a cui tornare');
|
|
166
|
+
await restartWith({ kind: 'rollback', from: CURRENT, to, at: new Date().toISOString() });
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|