mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Preserve persistent state in server removal guidance
Replace destructive default uninstall recipes with bounded service/container removal and explicit erasure warnings. Explain default Helm PVC ownership, temporary storage and updater/agent lifecycle boundaries; retain matching data and encryption keys. Contract-Neutral: Existing documentation and focused verification only; no installer, runtime, API, storage, privilege or lifecycle contract delta. Change-source: pulse-maintainer
This commit is contained in:
+96
-8
@@ -349,19 +349,107 @@ replace live runtime data or delete it to make a rollback fit.
|
||||
|
||||
## 🗑️ Uninstall
|
||||
|
||||
**Docker**:
|
||||
Removing or stopping the Pulse server also stops monitoring and alert delivery.
|
||||
This is not an update, rollback or password-reset procedure. Keep persistent
|
||||
data by default; deleting it erases configuration, history, credentials and
|
||||
the keys needed to decrypt that installation's data.
|
||||
|
||||
Before removing anything, identify the actual service, container or Helm
|
||||
release and **every effective data path** privately. Keep a consistent,
|
||||
private [full-state backup](MIGRATION.md#full-state-recovery), including the
|
||||
matching encryption key and deployment configuration. A configuration export
|
||||
alone is not a full backup. Let any in-progress Pulse update finish before
|
||||
removing its installation. Do not post backups, environment files or full
|
||||
container inspections in an issue.
|
||||
|
||||
### Docker and Compose: retain the data mount
|
||||
|
||||
The commands below assume `/data` is on a persistent named volume or bind
|
||||
mount, as in this guide's examples. **Do not use them for state stored only in
|
||||
the container's writable layer or temporary storage until it has a consistent
|
||||
backup.** A container created with `--rm` can also delete anonymous volumes
|
||||
when stopped. Container removal is not a backup.
|
||||
|
||||
For the `docker run` example, stop the container normally before removing it:
|
||||
|
||||
```bash
|
||||
docker rm -f pulse && docker volume rm pulse_data
|
||||
docker stop pulse
|
||||
docker rm pulse
|
||||
```
|
||||
|
||||
**Kubernetes**:
|
||||
For Compose, run this from the **existing** project, using its actual Pulse
|
||||
service name:
|
||||
|
||||
```bash
|
||||
helm uninstall pulse -n pulse
|
||||
docker compose stop pulse
|
||||
docker compose rm pulse
|
||||
```
|
||||
|
||||
**Systemd**:
|
||||
These commands do not request volume deletion. Keep the named volume or bind
|
||||
directory, original image/runtime and deployment settings; a reinstall must
|
||||
reattach the **same** data mount. Compose normally prefixes volume names with
|
||||
its project name, so creating a new project or an empty `pulse_data` volume
|
||||
can look like data loss. Do not add `-v`/`--volumes`, remove the volume or run
|
||||
volume pruning as part of a data-preserving removal.
|
||||
|
||||
### Kubernetes: check claim ownership before uninstalling
|
||||
|
||||
Do not assume `helm uninstall pulse -n pulse` retains data. The default Pulse
|
||||
chart creates a PersistentVolumeClaim without a keep policy; Helm removal can
|
||||
delete that claim, and the storage reclaim policy can delete its backing data.
|
||||
`persistence.existingClaim` refers to a separately managed claim, whose
|
||||
lifecycle must be checked separately. With `persistence.enabled=false`, the
|
||||
chart uses temporary `emptyDir` storage, lost when the pod is removed.
|
||||
|
||||
After verifying persistent storage and its backup, you can stop the default
|
||||
deployment without uninstalling the chart:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now pulse
|
||||
sudo rm -rf /etc/pulse /etc/systemd/system/pulse.service /usr/local/bin/pulse
|
||||
sudo systemctl daemon-reload
|
||||
kubectl scale deployment pulse \
|
||||
--namespace pulse \
|
||||
--replicas=0
|
||||
```
|
||||
|
||||
Use the actual deployment and namespace. Record the previous replica count
|
||||
and suspend any controller that would recreate pods. Before permanently
|
||||
uninstalling, verify the live claim's ownership, retention and reclaim policy
|
||||
and test recovery from the private backup; do not delete a PVC or namespace
|
||||
as a troubleshooting step.
|
||||
|
||||
### Systemd / Proxmox LXC: disable without erasing data
|
||||
|
||||
Identify the active service and any updater first; legacy installs can use
|
||||
`pulse-backend`, and custom installs can have other names. In an LXC, run
|
||||
these steps **inside the Pulse container**, not on the Proxmox host.
|
||||
|
||||
For the default signed installation, disable its update timer **if present**:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now \
|
||||
pulse-update.timer
|
||||
```
|
||||
|
||||
Then stop and disable the server:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now \
|
||||
pulse.service
|
||||
```
|
||||
|
||||
Disable other deployment-managed updaters through their owner too. These
|
||||
commands leave the binary, service account, units and data in place; they
|
||||
disable Pulse, not fully uninstall it. Keep `/etc/pulse` (or the actual custom
|
||||
data directory), its keys and authentication sources together. Do not remove
|
||||
the service account while retained files still need its ownership.
|
||||
|
||||
**Complete removal is a separate, destructive choice.** The signed server
|
||||
installer's `--uninstall` deletes its configuration/data directory without a
|
||||
keep-data prompt; it is not a data-preserving alternative. Use it only after
|
||||
checking the effective install/config paths and a tested private backup, when
|
||||
you intend that erasure. Do not delete `/bin/update` unless you have verified
|
||||
it belongs to Pulse; community-scripts containers can use a different helper.
|
||||
|
||||
Removing the server does not remove agents on monitored hosts. Use each
|
||||
agent's [uninstall procedure](UNIFIED_AGENT.md#uninstall), or
|
||||
[stop an orphaned agent](TROUBLESHOOTING.md#removed-pulse-server-but-pulse-agent-still-logs-connection-failures),
|
||||
so it does not keep retrying the absent server.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Render the shipped help and follow its recovery/agent links. Never execute
|
||||
// any removal command: this verifies guidance, not native uninstall behaviour.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const artifacts = path.join(root, 'node_modules', 'server-removal-help-browser');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(root, 'node_modules', '.vite-server-removal-help'),
|
||||
server: { host: '127.0.0.1', port: 5252, strictPort: true },
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
const results = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) {
|
||||
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true });
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 },
|
||||
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5252/browser-tests/docs-fragment-navigation.html?scenario=installation',
|
||||
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
const uninstall = page.getByRole('heading', { level: 2, name: '🗑️ Uninstall', exact: true });
|
||||
await uninstall.waitFor();
|
||||
if (engine === 'webkit') {
|
||||
await page.evaluate(async () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
|
||||
});
|
||||
}
|
||||
const section = await uninstall.evaluate((heading) => {
|
||||
const nodes = [];
|
||||
for (let node = heading.nextElementSibling; node; node = node.nextElementSibling)
|
||||
nodes.push(node);
|
||||
return {
|
||||
text: nodes.map((node) => node.innerText).join(' ').replace(/\s+/g, ' '),
|
||||
commands: nodes.filter((node) => node.tagName === 'PRE').flatMap((node) =>
|
||||
node.innerText.replace(/\\\n\s*/g, ' ').trim().split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())),
|
||||
commandBoxes: nodes.filter((node) => node.tagName === 'PRE').map((node) => ({
|
||||
clientWidth: node.clientWidth, scrollWidth: node.scrollWidth,
|
||||
})),
|
||||
};
|
||||
});
|
||||
for (const text of ['stops monitoring and alert delivery', 'Keep persistent data by default',
|
||||
'every effective data path', 'A configuration export alone is not a full backup',
|
||||
'container\'s writable layer or temporary storage', '--rm can also delete anonymous volumes',
|
||||
'reattach the same data mount', 'prefixes volume names', 'without a keep policy',
|
||||
'reclaim policy can delete its backing data', 'emptyDir storage, lost when the pod is removed',
|
||||
'inside the Pulse container', 'disable Pulse, not fully uninstall it',
|
||||
'without a keep-data prompt', 'Removing the server does not remove agents'])
|
||||
assert.ok(section.text.includes(text), text);
|
||||
assert.deepEqual(section.commands, [
|
||||
'docker stop pulse', 'docker rm pulse', 'docker compose stop pulse', 'docker compose rm pulse',
|
||||
'kubectl scale deployment pulse --namespace pulse --replicas=0',
|
||||
'sudo systemctl disable --now pulse-update.timer', 'sudo systemctl disable --now pulse.service',
|
||||
]);
|
||||
assert.ok(section.commandBoxes.every((box) => box.scrollWidth <= box.clientWidth + 1),
|
||||
'removal commands must fit without phone clipping');
|
||||
for (const [name, heading] of [
|
||||
['docker', uninstall],
|
||||
['kubernetes', page.getByRole('heading', { level: 3,
|
||||
name: 'Kubernetes: check claim ownership before uninstalling', exact: true })],
|
||||
['systemd', page.getByRole('heading', { level: 3,
|
||||
name: 'Systemd / Proxmox LXC: disable without erasing data', exact: true })],
|
||||
]) {
|
||||
await heading.evaluate((element) => element.scrollIntoView({ block: 'start' }));
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-${name}.png`) });
|
||||
}
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
const backup = page.getByRole('link', { name: 'full-state backup', exact: true });
|
||||
assert.equal(await backup.getAttribute('href'), '/docs/MIGRATION#full-state-recovery');
|
||||
await backup.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/docs/MIGRATION#full-state-recovery');
|
||||
await page.getByRole('heading', { level: 3, name: 'Full-state recovery', exact: true }).waitFor();
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.id), 'full-state-recovery');
|
||||
await page.goBack();
|
||||
await uninstall.waitFor();
|
||||
const agent = page.getByRole('link', { name: 'uninstall procedure', exact: true });
|
||||
assert.equal(await agent.getAttribute('href'), '/docs/UNIFIED_AGENT#uninstall');
|
||||
await agent.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/docs/UNIFIED_AGENT#uninstall');
|
||||
await page.getByRole('heading', { level: 2, name: 'Uninstall', exact: true }).waitFor();
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.id), 'uninstall');
|
||||
assert.deepEqual(errors, []);
|
||||
results.push({ engine, version: browser.version(), width,
|
||||
persistentDataWarning: true, commandsFit: true, noDocumentOverflow: true,
|
||||
helmRetentionWarning: true, installerErasureWarning: true,
|
||||
keyboardFullStateRecovery: true, keyboardAgentUninstall: true, errors });
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
const files = ['public/docs/INSTALL.md', 'browser-tests/server-removal-help.cjs',
|
||||
'browser-tests/docs-fragment-navigation.tsx', 'src/features/docs/docMarkdown.ts',
|
||||
'src/pages/Docs.tsx'].map((file) => ({ file,
|
||||
sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') }));
|
||||
const result = { result: 'passed', playwright: require('playwright/package.json').version,
|
||||
scope: 'shipped server-removal help rendering and keyboard links; no removal/native commands executed',
|
||||
dompurify: JSON.parse(fs.readFileSync(path.join(root, 'node_modules/dompurify/package.json'))).version,
|
||||
files, results };
|
||||
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -349,19 +349,107 @@ replace live runtime data or delete it to make a rollback fit.
|
||||
|
||||
## 🗑️ Uninstall
|
||||
|
||||
**Docker**:
|
||||
Removing or stopping the Pulse server also stops monitoring and alert delivery.
|
||||
This is not an update, rollback or password-reset procedure. Keep persistent
|
||||
data by default; deleting it erases configuration, history, credentials and
|
||||
the keys needed to decrypt that installation's data.
|
||||
|
||||
Before removing anything, identify the actual service, container or Helm
|
||||
release and **every effective data path** privately. Keep a consistent,
|
||||
private [full-state backup](MIGRATION.md#full-state-recovery), including the
|
||||
matching encryption key and deployment configuration. A configuration export
|
||||
alone is not a full backup. Let any in-progress Pulse update finish before
|
||||
removing its installation. Do not post backups, environment files or full
|
||||
container inspections in an issue.
|
||||
|
||||
### Docker and Compose: retain the data mount
|
||||
|
||||
The commands below assume `/data` is on a persistent named volume or bind
|
||||
mount, as in this guide's examples. **Do not use them for state stored only in
|
||||
the container's writable layer or temporary storage until it has a consistent
|
||||
backup.** A container created with `--rm` can also delete anonymous volumes
|
||||
when stopped. Container removal is not a backup.
|
||||
|
||||
For the `docker run` example, stop the container normally before removing it:
|
||||
|
||||
```bash
|
||||
docker rm -f pulse && docker volume rm pulse_data
|
||||
docker stop pulse
|
||||
docker rm pulse
|
||||
```
|
||||
|
||||
**Kubernetes**:
|
||||
For Compose, run this from the **existing** project, using its actual Pulse
|
||||
service name:
|
||||
|
||||
```bash
|
||||
helm uninstall pulse -n pulse
|
||||
docker compose stop pulse
|
||||
docker compose rm pulse
|
||||
```
|
||||
|
||||
**Systemd**:
|
||||
These commands do not request volume deletion. Keep the named volume or bind
|
||||
directory, original image/runtime and deployment settings; a reinstall must
|
||||
reattach the **same** data mount. Compose normally prefixes volume names with
|
||||
its project name, so creating a new project or an empty `pulse_data` volume
|
||||
can look like data loss. Do not add `-v`/`--volumes`, remove the volume or run
|
||||
volume pruning as part of a data-preserving removal.
|
||||
|
||||
### Kubernetes: check claim ownership before uninstalling
|
||||
|
||||
Do not assume `helm uninstall pulse -n pulse` retains data. The default Pulse
|
||||
chart creates a PersistentVolumeClaim without a keep policy; Helm removal can
|
||||
delete that claim, and the storage reclaim policy can delete its backing data.
|
||||
`persistence.existingClaim` refers to a separately managed claim, whose
|
||||
lifecycle must be checked separately. With `persistence.enabled=false`, the
|
||||
chart uses temporary `emptyDir` storage, lost when the pod is removed.
|
||||
|
||||
After verifying persistent storage and its backup, you can stop the default
|
||||
deployment without uninstalling the chart:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now pulse
|
||||
sudo rm -rf /etc/pulse /etc/systemd/system/pulse.service /usr/local/bin/pulse
|
||||
sudo systemctl daemon-reload
|
||||
kubectl scale deployment pulse \
|
||||
--namespace pulse \
|
||||
--replicas=0
|
||||
```
|
||||
|
||||
Use the actual deployment and namespace. Record the previous replica count
|
||||
and suspend any controller that would recreate pods. Before permanently
|
||||
uninstalling, verify the live claim's ownership, retention and reclaim policy
|
||||
and test recovery from the private backup; do not delete a PVC or namespace
|
||||
as a troubleshooting step.
|
||||
|
||||
### Systemd / Proxmox LXC: disable without erasing data
|
||||
|
||||
Identify the active service and any updater first; legacy installs can use
|
||||
`pulse-backend`, and custom installs can have other names. In an LXC, run
|
||||
these steps **inside the Pulse container**, not on the Proxmox host.
|
||||
|
||||
For the default signed installation, disable its update timer **if present**:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now \
|
||||
pulse-update.timer
|
||||
```
|
||||
|
||||
Then stop and disable the server:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now \
|
||||
pulse.service
|
||||
```
|
||||
|
||||
Disable other deployment-managed updaters through their owner too. These
|
||||
commands leave the binary, service account, units and data in place; they
|
||||
disable Pulse, not fully uninstall it. Keep `/etc/pulse` (or the actual custom
|
||||
data directory), its keys and authentication sources together. Do not remove
|
||||
the service account while retained files still need its ownership.
|
||||
|
||||
**Complete removal is a separate, destructive choice.** The signed server
|
||||
installer's `--uninstall` deletes its configuration/data directory without a
|
||||
keep-data prompt; it is not a data-preserving alternative. Use it only after
|
||||
checking the effective install/config paths and a tested private backup, when
|
||||
you intend that erasure. Do not delete `/bin/update` unless you have verified
|
||||
it belongs to Pulse; community-scripts containers can use a different helper.
|
||||
|
||||
Removing the server does not remove agents on monitored hosts. Use each
|
||||
agent's [uninstall procedure](UNIFIED_AGENT.md#uninstall), or
|
||||
[stop an orphaned agent](TROUBLESHOOTING.md#removed-pulse-server-but-pulse-agent-still-logs-connection-failures),
|
||||
so it does not keep retrying the absent server.
|
||||
|
||||
@@ -61,6 +61,84 @@ function getRuntimeSourceFiles(dir: string): string[] {
|
||||
}
|
||||
|
||||
describe('docsLinks', () => {
|
||||
it('separates server removal from persistent-data erasure', () => {
|
||||
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
|
||||
const removal = installation.split('## 🗑️ Uninstall')[1];
|
||||
expect(removal).toContain('stops monitoring and alert delivery');
|
||||
expect(removal).toContain('Keep persistent\ndata by default');
|
||||
expect(removal).toContain('every effective data path');
|
||||
expect(removal).toContain('[full-state backup](MIGRATION.md#full-state-recovery)');
|
||||
expect(removal).toContain('A configuration export\nalone is not a full backup');
|
||||
expect(removal).toContain('Let any in-progress Pulse update finish');
|
||||
expect(removal).toContain('without a\nkeep-data prompt');
|
||||
expect(removal).toContain('Do not remove\nthe service account');
|
||||
expect(removal).toContain('Do not delete `/bin/update` unless');
|
||||
expect(removal).toContain('Removing the server does not remove agents');
|
||||
|
||||
// Pin the copyable recipes, not merely the surrounding warnings. The old
|
||||
// Docker/root commands erased the complete data store as part of removal.
|
||||
const commands = [...removal.matchAll(/```bash\n([\s\S]*?)```/g)].flatMap((match) =>
|
||||
match[1]
|
||||
.replace(/\\\n\s*/g, ' ')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim()),
|
||||
);
|
||||
expect(commands).toEqual([
|
||||
'docker stop pulse',
|
||||
'docker rm pulse',
|
||||
'docker compose stop pulse',
|
||||
'docker compose rm pulse',
|
||||
'kubectl scale deployment pulse --namespace pulse --replicas=0',
|
||||
'sudo systemctl disable --now pulse-update.timer',
|
||||
'sudo systemctl disable --now pulse.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds container-removal advice to retained persistent mounts', () => {
|
||||
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
|
||||
const docker = (
|
||||
installation.split('### Docker and Compose: retain the data mount')[1] ?? ''
|
||||
).split('### Kubernetes:')[0];
|
||||
expect(docker).toContain('persistent named volume or bind');
|
||||
expect(docker).toContain("container's writable layer or temporary storage");
|
||||
expect(docker).toContain('`--rm` can also delete anonymous volumes');
|
||||
expect(docker).toContain('**existing** project');
|
||||
expect(docker).toContain('reattach the **same** data mount');
|
||||
expect(docker).toContain('prefixes volume names');
|
||||
expect(docker).toContain('Do not add `-v`/`--volumes`');
|
||||
expect(docker).not.toContain('docker rm -f');
|
||||
expect(docker).not.toContain('docker volume rm');
|
||||
});
|
||||
|
||||
it('does not promise Helm retention where the current chart owns the claim', () => {
|
||||
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
|
||||
const kubernetes = (
|
||||
installation.split('### Kubernetes: check claim ownership before uninstalling')[1] ?? ''
|
||||
).split('### Systemd / Proxmox LXC:')[0];
|
||||
expect(kubernetes).toContain('Do not assume `helm uninstall pulse -n pulse` retains data');
|
||||
expect(kubernetes).toContain('without a keep policy');
|
||||
expect(kubernetes).toContain('reclaim policy can delete its backing data');
|
||||
expect(kubernetes).toContain('`persistence.existingClaim`');
|
||||
expect(kubernetes).toContain('`emptyDir` storage, lost when the pod is removed');
|
||||
expect(kubernetes.indexOf('After verifying persistent storage and its backup')).toBeLessThan(
|
||||
kubernetes.indexOf('```bash'),
|
||||
);
|
||||
expect(kubernetes).toContain('suspend any controller');
|
||||
const chart = readFileSync(
|
||||
path.join(repoRoot, 'deploy', 'helm', 'pulse', 'templates', 'pvc.yaml'),
|
||||
'utf8',
|
||||
);
|
||||
expect(chart).toContain('kind: PersistentVolumeClaim');
|
||||
expect(chart).toContain('(not .Values.persistence.existingClaim)');
|
||||
expect(chart).not.toContain('helm.sh/resource-policy');
|
||||
const values = readFileSync(
|
||||
path.join(repoRoot, 'deploy', 'helm', 'pulse', 'values.yaml'),
|
||||
'utf8',
|
||||
);
|
||||
expect(values.split('persistence:')[1].split('server:')[0]).toContain('annotations: {}');
|
||||
});
|
||||
|
||||
it('keeps custom systemd server help on the signed non-root install path', () => {
|
||||
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
|
||||
const custom = installation
|
||||
|
||||
Reference in New Issue
Block a user