orionplace-react 0.2.1 → 0.3.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/README.md +37 -4
- package/bin/orionplace-react.mjs +37 -2
- package/package.json +1 -1
- package/src/install.mjs +355 -0
- package/src/project-readme.mjs +7 -0
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ setup CLI and the full storefront source, so every page and component in your
|
|
|
9
9
|
project is yours to change.
|
|
10
10
|
|
|
11
11
|
- **Setup CLI:** `init`, `set backend`, `add frontend`, `set mimic` and `update`.
|
|
12
|
+
- **Server install:** `install` builds the storefront on your Linux server and
|
|
13
|
+
runs it as a system service behind nginx with HTTPS.
|
|
12
14
|
- **API gateway:** the browser calls `/api/...` on your own domain; the server
|
|
13
15
|
forwards it to your marketplace backend with cookies bound to the storefront.
|
|
14
16
|
- **Typed store:** one Application Load request fills it before the first render.
|
|
@@ -19,8 +21,8 @@ project is yours to change.
|
|
|
19
21
|
|
|
20
22
|
**Contents:** [Quick start](#quick-start-from-zero) ·
|
|
21
23
|
[Existing project](#existing-nextjs-16-project) · [Components](#components) ·
|
|
22
|
-
[Commands](#commands) · [
|
|
23
|
-
[Update](#update-an-existing-project)
|
|
24
|
+
[Commands](#commands) · [Server install](#install-on-your-server) ·
|
|
25
|
+
[Android app](#android-app) · [Update](#update-an-existing-project)
|
|
24
26
|
|
|
25
27
|
Requires Node.js 20.9+ and a Node server deployment. Static export and edge-only
|
|
26
28
|
hosting are not supported by this filesystem-based integration; only the Android
|
|
@@ -314,6 +316,7 @@ npx orionplace-react add frontend shop.example.com
|
|
|
314
316
|
npx orionplace-react add frontend www.shop.example.com
|
|
315
317
|
npx orionplace-react remove frontend www.shop.example.com
|
|
316
318
|
npx orionplace-react set mimic shop.example.com
|
|
319
|
+
sudo npx orionplace-react install
|
|
317
320
|
npx orionplace-react help
|
|
318
321
|
```
|
|
319
322
|
|
|
@@ -330,8 +333,38 @@ npm run orionplace:update
|
|
|
330
333
|
```
|
|
331
334
|
|
|
332
335
|
`npm set` belongs to npm's own configuration system; use the commands above.
|
|
333
|
-
|
|
334
|
-
change your account
|
|
336
|
+
The setup commands configure local integration files. They do not provision a
|
|
337
|
+
backend, change your account or create DNS records. `install` deploys the
|
|
338
|
+
storefront on your server; see [Install on your server](#install-on-your-server).
|
|
339
|
+
|
|
340
|
+
## Install on your server
|
|
341
|
+
|
|
342
|
+
Prepare a Linux server once with the OrionMarketplace server script: it installs
|
|
343
|
+
Node.js, nginx and certbot and adds the `orionplace` command
|
|
344
|
+
([server guide](https://orionmarket.place/en/sandbox/install)).
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
curl -fsSLo orionplace.sh https://orionmarket.place/downloads/orionplace.sh
|
|
348
|
+
sudo sh orionplace.sh setup
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Then create the storefront in `/srv/orionplace` and install it:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
cd /srv/orionplace
|
|
355
|
+
sudo npx --yes orionplace-react init shop
|
|
356
|
+
cd shop
|
|
357
|
+
sudo npm install
|
|
358
|
+
sudo npx orionplace-react install
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`install` asks for your backend and storefront domain when they are missing,
|
|
362
|
+
builds the project, runs it as the system service `orionplace-shop` on a free
|
|
363
|
+
local port and connects each storefront domain with nginx and a Let's Encrypt
|
|
364
|
+
certificate. Run it again after code or package updates; the service keeps its
|
|
365
|
+
port. Options: `--name <service>`, `--port <port>`, `--user <user>`,
|
|
366
|
+
`--no-domains`, `--yes`, `--dry-run`. Full guide:
|
|
367
|
+
[Install a storefront](https://orionmarket.place/en/sandbox/install/storefront?framework=react).
|
|
335
368
|
|
|
336
369
|
## Generated files
|
|
337
370
|
|
package/bin/orionplace-react.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { setupProject } from '../src/setup.mjs';
|
|
3
3
|
import { updateProject } from '../src/update.mjs';
|
|
4
|
+
import { installService } from '../src/install.mjs';
|
|
4
5
|
import { readFile } from 'node:fs/promises';
|
|
5
6
|
import { resolve } from 'node:path';
|
|
6
7
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -29,8 +30,16 @@ Commands for an existing project (run from its root):
|
|
|
29
30
|
npx orionplace-react set mimic <existing-frontend.example.com>
|
|
30
31
|
npx orionplace-react set frontend <shop.example.com>
|
|
31
32
|
|
|
33
|
+
On your Linux server (run from the project root):
|
|
34
|
+
sudo npx orionplace-react install
|
|
35
|
+
Builds the storefront, installs it as a system service (systemd or OpenRC)
|
|
36
|
+
and, when the orionplace server tool is set up, connects its domains with
|
|
37
|
+
nginx and HTTPS. Run it again after changes or an update to rebuild and restart.
|
|
38
|
+
Server tool: https://orionmarket.place/downloads/orionplace.sh
|
|
39
|
+
|
|
32
40
|
Options: --cwd <directory> --dry-run --help --version
|
|
33
41
|
Update option: --local (apply this CLI version instead of fetching the latest release)
|
|
42
|
+
Install options: --name <service> --port <port> --user <user> --no-domains --yes
|
|
34
43
|
|
|
35
44
|
Update downloads the latest release, refreshes unedited source, and installs dependencies.
|
|
36
45
|
It preserves edited source, domain mappings and MIMIC. --dry-run leaves project files unchanged.
|
|
@@ -53,10 +62,16 @@ try {
|
|
|
53
62
|
let cwd = process.cwd();
|
|
54
63
|
let dryRun = false;
|
|
55
64
|
let local = false;
|
|
65
|
+
const install = { domains: true, yes: false };
|
|
56
66
|
for (let index = 0; index < argv.length; index++) {
|
|
57
67
|
if (argv[index] === '--dry-run') dryRun = true;
|
|
58
68
|
else if (argv[index] === '--local') local = true;
|
|
59
|
-
else if (argv[index] === '--
|
|
69
|
+
else if (argv[index] === '--no-domains') install.domains = false;
|
|
70
|
+
else if (argv[index] === '--yes' || argv[index] === '-y') install.yes = true;
|
|
71
|
+
else if (['--name', '--port', '--user'].includes(argv[index])) {
|
|
72
|
+
if (!argv[index + 1]) throw new Error(`${argv[index]} needs a value`);
|
|
73
|
+
install[argv[index].slice(2)] = argv[++index];
|
|
74
|
+
} else if (argv[index] === '--cwd') {
|
|
60
75
|
if (!argv[index + 1]) throw new Error('--cwd needs a directory');
|
|
61
76
|
cwd = resolve(argv[++index]);
|
|
62
77
|
} else if (argv[index].startsWith('--')) throw new Error(`Unknown option: ${argv[index]}`);
|
|
@@ -64,8 +79,28 @@ try {
|
|
|
64
79
|
}
|
|
65
80
|
const [command, target] = positional;
|
|
66
81
|
if (local && command !== 'update') throw new Error('--local is only supported by update.');
|
|
82
|
+
if (command !== 'install' && (install.name || install.port || install.user || !install.domains || install.yes)) {
|
|
83
|
+
throw new Error('--name, --port, --user, --no-domains and --yes are install options.');
|
|
84
|
+
}
|
|
67
85
|
let result;
|
|
68
|
-
if (command === '
|
|
86
|
+
if (command === 'install' && positional.length === 1) {
|
|
87
|
+
const done = await installService({ cwd, dryRun, ...install });
|
|
88
|
+
if (done.dryRun) {
|
|
89
|
+
console.log(`Planned service orionplace-${done.name} on 127.0.0.1:${done.port} as ${done.user}`);
|
|
90
|
+
console.log(`Domains: ${done.frontends.join(', ') || 'none yet'} -> ${done.backend || 'no backend yet'}`);
|
|
91
|
+
console.log(`\n${done.unitPath}:\n${done.unit}`);
|
|
92
|
+
} else {
|
|
93
|
+
console.log(`\nInstalled orionplace-${done.name} (port ${done.port}, user ${done.user}).`);
|
|
94
|
+
for (const domain of done.frontends) {
|
|
95
|
+
if (!done.connected) console.log(` - ${domain}: not connected to nginx (see above)`);
|
|
96
|
+
else if (done.failed.includes(domain)) console.log(` ✗ ${domain}: not connected (see above)`);
|
|
97
|
+
else console.log(` ✓ https://${domain}`);
|
|
98
|
+
}
|
|
99
|
+
if (done.failed.length) console.log('Fix the problems above, then run: sudo orionplace domain add <domain>');
|
|
100
|
+
console.log(`Manage it with: orionplace app status ${done.name} | orionplace app logs ${done.name}`);
|
|
101
|
+
console.log('After code changes or npx orionplace-react update, run sudo npx orionplace-react install again.');
|
|
102
|
+
}
|
|
103
|
+
} else if (command === 'init' && positional.length <= 2) {
|
|
69
104
|
result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
|
|
70
105
|
} else if (command === 'update' && positional.length === 1) {
|
|
71
106
|
result = await updateProject({ cwd, dryRun, local });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Next.js 16 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Installs the built storefront as a system service on a Linux server and
|
|
2
|
+
// connects its domains through the "orionplace" server tool
|
|
3
|
+
// (https://orionmarket.place/downloads/orionplace.sh) when that tool is set up.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { request } from 'node:http';
|
|
8
|
+
import { createServer } from 'node:net';
|
|
9
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { createInterface } from 'node:readline/promises';
|
|
11
|
+
import { setupProject } from './setup.mjs';
|
|
12
|
+
|
|
13
|
+
export const framework = {
|
|
14
|
+
id: 'react',
|
|
15
|
+
title: 'Next.js',
|
|
16
|
+
dependency: 'next',
|
|
17
|
+
output: '.next/BUILD_ID',
|
|
18
|
+
start: port => ['node_modules/next/dist/bin/next', 'start', '--hostname', '127.0.0.1', '--port', String(port)],
|
|
19
|
+
environment: {},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const defaultPaths = {
|
|
23
|
+
etc: process.env.ORIONPLACE_ETC || '/etc/orionplace',
|
|
24
|
+
systemd: '/etc/systemd/system',
|
|
25
|
+
openrc: '/etc/init.d',
|
|
26
|
+
logs: '/var/log/orionplace',
|
|
27
|
+
srv: '/srv/orionplace',
|
|
28
|
+
tool: ['/usr/local/bin/orionplace', '/usr/bin/orionplace'],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const SERVICE_USER = 'orionplace';
|
|
32
|
+
|
|
33
|
+
// run(command, args, { cwd, capture, env }) -> { code, output }
|
|
34
|
+
export function runCommand(command, args, { cwd, capture = false, env = process.env } = {}) {
|
|
35
|
+
return new Promise((done, reject) => {
|
|
36
|
+
const child = spawn(command, args, { cwd, env, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' });
|
|
37
|
+
let output = '';
|
|
38
|
+
if (capture) {
|
|
39
|
+
child.stdout.on('data', chunk => { output += chunk; });
|
|
40
|
+
child.stderr.on('data', chunk => { output += chunk; });
|
|
41
|
+
}
|
|
42
|
+
child.once('error', reject);
|
|
43
|
+
child.once('exit', code => done({ code: code ?? 1, output }));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseConf(text) {
|
|
48
|
+
const values = {};
|
|
49
|
+
for (const line of text.split('\n')) {
|
|
50
|
+
const match = /^([A-Z_]+)=(.*)$/.exec(line);
|
|
51
|
+
if (match) values[match[1]] = match[2];
|
|
52
|
+
}
|
|
53
|
+
return values;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readApps(paths = defaultPaths) {
|
|
57
|
+
const directory = join(paths.etc, 'apps');
|
|
58
|
+
let names = [];
|
|
59
|
+
try { names = (await readdir(directory)).filter(name => name.endsWith('.conf')); }
|
|
60
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
61
|
+
const apps = [];
|
|
62
|
+
for (const name of names) apps.push(parseConf(await readFile(join(directory, name), 'utf8')));
|
|
63
|
+
return apps;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function serviceName(value) {
|
|
67
|
+
const name = value.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
68
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error('Choose a service name with letters, digits and dashes: --name shop');
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function portIsFree(port) {
|
|
73
|
+
return new Promise(done => {
|
|
74
|
+
const server = createServer();
|
|
75
|
+
server.once('error', () => done(false));
|
|
76
|
+
server.listen({ port, host: '127.0.0.1', exclusive: true }, () => server.close(() => done(true)));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function choosePort(apps, wanted, isFree = portIsFree) {
|
|
81
|
+
const taken = new Set(apps.map(app => Number(app.PORT)));
|
|
82
|
+
if (wanted) {
|
|
83
|
+
if (!Number.isInteger(wanted) || wanted < 1024 || wanted > 65535) throw new Error('Use a port between 1024 and 65535.');
|
|
84
|
+
return wanted;
|
|
85
|
+
}
|
|
86
|
+
for (let port = 3000; port < 4000; port++) {
|
|
87
|
+
if (!taken.has(port) && await isFree(port)) return port;
|
|
88
|
+
}
|
|
89
|
+
throw new Error('No free port between 3000 and 3999. Choose one with --port.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function passwdEntry(user) {
|
|
93
|
+
const text = await readFile('/etc/passwd', 'utf8');
|
|
94
|
+
for (const line of text.split('\n')) {
|
|
95
|
+
const [name, , uid, gid, , home] = line.split(':');
|
|
96
|
+
if (name === user) return { name, uid: Number(uid), gid: Number(gid), home };
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function userByUid(uid) {
|
|
102
|
+
const text = await readFile('/etc/passwd', 'utf8');
|
|
103
|
+
for (const line of text.split('\n')) {
|
|
104
|
+
const [name, , id, gid, , home] = line.split(':');
|
|
105
|
+
if (Number(id) === uid && name) return { name, uid, gid: Number(gid), home };
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Every parent folder must let the service user through.
|
|
111
|
+
async function checkReachable(root, account) {
|
|
112
|
+
let cursor = dirname(root);
|
|
113
|
+
while (true) {
|
|
114
|
+
const info = await stat(cursor);
|
|
115
|
+
const open = info.uid === account.uid ? info.mode & 0o100 : info.gid === account.gid ? info.mode & 0o010 : info.mode & 0o001;
|
|
116
|
+
if (!open) {
|
|
117
|
+
throw new Error(`The folder ${cursor} is closed to the service user ${account.name}. Move the storefront to ${defaultPaths.srv}/ (for example ${defaultPaths.srv}/${basename(root)}) and run install there.`);
|
|
118
|
+
}
|
|
119
|
+
if (cursor === dirname(cursor)) return;
|
|
120
|
+
cursor = dirname(cursor);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function writeAtomic(path, content, mode = 0o644) {
|
|
125
|
+
await mkdir(dirname(path), { recursive: true });
|
|
126
|
+
const temporary = `${path}.orionplace-${process.pid}.tmp`;
|
|
127
|
+
await writeFile(temporary, content, { mode });
|
|
128
|
+
await rename(temporary, path);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function systemdUnit({ name, root, user, group, port, node, env }) {
|
|
132
|
+
const environment = Object.entries({ NODE_ENV: 'production', HOST: '127.0.0.1', PORT: String(port), SITEMAPS_DIR: join(root, 'sitemaps'), ...env })
|
|
133
|
+
.map(([key, value]) => `Environment=${key}=${value}`).join('\n');
|
|
134
|
+
const args = [...(existsSync(join(root, '.env')) ? ['--env-file=.env'] : []), ...framework.start(port)];
|
|
135
|
+
return `# Installed by "orionplace-${framework.id} install". Run it again after changes;
|
|
136
|
+
# manage with: orionplace app status|logs|restart ${name}
|
|
137
|
+
[Unit]
|
|
138
|
+
Description=OrionMarketplace storefront ${name} (${framework.title})
|
|
139
|
+
After=network-online.target
|
|
140
|
+
Wants=network-online.target
|
|
141
|
+
|
|
142
|
+
[Service]
|
|
143
|
+
Type=simple
|
|
144
|
+
User=${user}
|
|
145
|
+
Group=${group}
|
|
146
|
+
WorkingDirectory=${root}
|
|
147
|
+
${environment}
|
|
148
|
+
ExecStart=${node} ${args.join(' ')}
|
|
149
|
+
Restart=on-failure
|
|
150
|
+
RestartSec=3
|
|
151
|
+
LimitNOFILE=65535
|
|
152
|
+
NoNewPrivileges=true
|
|
153
|
+
PrivateTmp=true
|
|
154
|
+
ProtectSystem=full
|
|
155
|
+
|
|
156
|
+
[Install]
|
|
157
|
+
WantedBy=multi-user.target
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function openrcScript({ name, root, user, group, port, node, env, logs }) {
|
|
162
|
+
const environment = Object.entries({ NODE_ENV: 'production', HOST: '127.0.0.1', PORT: String(port), SITEMAPS_DIR: join(root, 'sitemaps'), ...env })
|
|
163
|
+
.map(([key, value]) => `export ${key}="${value}"`).join('\n');
|
|
164
|
+
const args = [...(existsSync(join(root, '.env')) ? ['--env-file=.env'] : []), ...framework.start(port)];
|
|
165
|
+
return `#!/sbin/openrc-run
|
|
166
|
+
# Installed by "orionplace-${framework.id} install". Run it again after changes;
|
|
167
|
+
# manage with: orionplace app status|logs|restart ${name}
|
|
168
|
+
name="orionplace-${name}"
|
|
169
|
+
description="OrionMarketplace storefront ${name} (${framework.title})"
|
|
170
|
+
supervisor=supervise-daemon
|
|
171
|
+
respawn_delay=3
|
|
172
|
+
command="${node}"
|
|
173
|
+
command_args="${args.join(' ')}"
|
|
174
|
+
command_user="${user}:${group}"
|
|
175
|
+
directory="${root}"
|
|
176
|
+
output_log="${logs}/${name}.log"
|
|
177
|
+
error_log="${logs}/${name}.log"
|
|
178
|
+
${environment}
|
|
179
|
+
|
|
180
|
+
depend() {
|
|
181
|
+
need net
|
|
182
|
+
after firewall
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
start_pre() {
|
|
186
|
+
checkpath --directory --owner ${user}:${group} --mode 0755 ${logs}
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function prompt(question) {
|
|
192
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
193
|
+
try { return (await terminal.question(question)).trim(); }
|
|
194
|
+
finally { terminal.close(); }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function log(message) { console.log(message); }
|
|
198
|
+
|
|
199
|
+
// /usr/local/bin/node from the server tool follows Node.js updates; the
|
|
200
|
+
// versioned path of the running binary would not.
|
|
201
|
+
function stableNode() {
|
|
202
|
+
return ['/usr/local/bin/node', '/usr/bin/node'].find(path => existsSync(path)) || process.execPath;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fills in the backend and first storefront domain when the project has none.
|
|
206
|
+
async function ensureDomains(root, { yes }) {
|
|
207
|
+
const read = async () => JSON.parse(await readFile(join(root, 'orionplace/settings.json'), 'utf8'));
|
|
208
|
+
let settings = await read();
|
|
209
|
+
const ask = process.stdin.isTTY && !yes;
|
|
210
|
+
if (!settings.backend) {
|
|
211
|
+
if (!ask) throw new Error(`Set the personal backend first: npx orionplace-${framework.id} set backend my-shop.orionplace.app`);
|
|
212
|
+
log('Your personal backend is shown in your orionmarket.place profile, for example my-shop.orionplace.app.');
|
|
213
|
+
await setupProject({ cwd: root, field: 'backend', value: await prompt('Personal backend domain: ') });
|
|
214
|
+
settings = await read();
|
|
215
|
+
}
|
|
216
|
+
if (!settings.frontends?.length) {
|
|
217
|
+
if (!ask) throw new Error(`Add the storefront domain first: npx orionplace-${framework.id} add frontend shop.example.com`);
|
|
218
|
+
await setupProject({ cwd: root, action: 'add', field: 'frontend', value: await prompt('Storefront domain (for example shop.example.com): ') });
|
|
219
|
+
settings = await read();
|
|
220
|
+
}
|
|
221
|
+
return settings;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The storefront's home page as the public domain sees it -> HTTP status, 0 when down.
|
|
225
|
+
function homeStatus(port, host) {
|
|
226
|
+
return new Promise(done => {
|
|
227
|
+
const call = request({ host: '127.0.0.1', port, path: '/', headers: { host }, timeout: 15000 }, response => {
|
|
228
|
+
response.resume();
|
|
229
|
+
done(response.statusCode);
|
|
230
|
+
});
|
|
231
|
+
call.once('timeout', () => call.destroy());
|
|
232
|
+
call.once('error', () => done(0));
|
|
233
|
+
call.end();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function waitForHttp(port, host, seconds = 90) {
|
|
238
|
+
const until = Date.now() + seconds * 1000;
|
|
239
|
+
while (Date.now() < until) {
|
|
240
|
+
const status = await homeStatus(port, host);
|
|
241
|
+
if (status) return status;
|
|
242
|
+
await new Promise(done => setTimeout(done, 1000));
|
|
243
|
+
}
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function installService({
|
|
248
|
+
cwd = process.cwd(), name, port, user, build = true, domains = true, yes = false, dryRun = false,
|
|
249
|
+
paths = defaultPaths, run = runCommand, uid = process.getuid?.(), isFree = portIsFree,
|
|
250
|
+
systemd = existsSync('/run/systemd/system'), node = stableNode(),
|
|
251
|
+
} = {}) {
|
|
252
|
+
if (process.platform !== 'linux') throw new Error('install sets up a Linux server service. Run it on your VPS.');
|
|
253
|
+
if (uid !== 0 && !dryRun) throw new Error(`install needs root: sudo npx orionplace-${framework.id} install`);
|
|
254
|
+
const root = resolve(cwd);
|
|
255
|
+
if (!/^\/[A-Za-z0-9._/@+-]+$/.test(root)) throw new Error(`Use a storefront folder path without spaces or special characters (for example ${paths.srv}/shop), not ${root}.`);
|
|
256
|
+
let pkg;
|
|
257
|
+
try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); }
|
|
258
|
+
catch { throw new Error(`Run install in your storefront folder (created with: npx orionplace-${framework.id} init).`); }
|
|
259
|
+
if (!{ ...pkg.devDependencies, ...pkg.dependencies }[framework.dependency] || !existsSync(join(root, 'orionplace/settings.json'))) {
|
|
260
|
+
throw new Error(`This folder is not an Orionplace ${framework.title} storefront. Run install in the folder created with: npx orionplace-${framework.id} init`);
|
|
261
|
+
}
|
|
262
|
+
const openrc = !systemd && existsSync('/sbin/openrc-run');
|
|
263
|
+
if (!systemd && !openrc && !dryRun) throw new Error('Neither systemd nor OpenRC was found; start the storefront with your own process manager (see orionplace/README.md, Deploy on your VPS).');
|
|
264
|
+
|
|
265
|
+
const settings = dryRun ? JSON.parse(await readFile(join(root, 'orionplace/settings.json'), 'utf8')) : await ensureDomains(root, { yes });
|
|
266
|
+
const apps = await readApps(paths);
|
|
267
|
+
const previous = apps.find(app => app.DIR === root);
|
|
268
|
+
const service = serviceName(name || previous?.NAME || basename(root));
|
|
269
|
+
if (apps.some(app => app.NAME === service && app.DIR !== root)) {
|
|
270
|
+
throw new Error(`Another storefront already uses the name ${service} (${apps.find(app => app.NAME === service).DIR}). Choose one with --name.`);
|
|
271
|
+
}
|
|
272
|
+
const chosen = await choosePort(apps.filter(app => app.DIR !== root), port ? Number(port) : Number(previous?.PORT) || 0, isFree);
|
|
273
|
+
|
|
274
|
+
// The service runs as the folder's owner, or as "orionplace" for root-owned folders.
|
|
275
|
+
const owner = await stat(root);
|
|
276
|
+
let account = user ? await passwdEntry(user) : owner.uid === 0 ? await passwdEntry(SERVICE_USER) : await userByUid(owner.uid);
|
|
277
|
+
if (user && !account) throw new Error(`No system user ${user}.`);
|
|
278
|
+
if (!account && !dryRun) {
|
|
279
|
+
if (existsSync('/usr/sbin/useradd') || existsSync('/sbin/useradd')) {
|
|
280
|
+
await run('useradd', ['--system', '--home-dir', paths.srv, '--shell', '/sbin/nologin', '--user-group', SERVICE_USER], { capture: true });
|
|
281
|
+
} else {
|
|
282
|
+
await run('addgroup', ['-S', SERVICE_USER], { capture: true });
|
|
283
|
+
await run('adduser', ['-S', '-D', '-H', '-h', paths.srv, '-s', '/sbin/nologin', '-G', SERVICE_USER, SERVICE_USER], { capture: true });
|
|
284
|
+
}
|
|
285
|
+
account = await passwdEntry(SERVICE_USER);
|
|
286
|
+
if (!account) throw new Error(`Could not create the system user ${SERVICE_USER}.`);
|
|
287
|
+
}
|
|
288
|
+
account ||= { name: SERVICE_USER, uid: -1, gid: -1 };
|
|
289
|
+
const group = account.gid >= 0 ? (await readFile('/etc/group', 'utf8')).split('\n').map(line => line.split(':'))
|
|
290
|
+
.find(([, , gid]) => Number(gid) === account.gid)?.[0] || account.name : account.name;
|
|
291
|
+
if (account.uid >= 0) await checkReachable(root, account);
|
|
292
|
+
|
|
293
|
+
const values = { name: service, root, user: account.name, group, port: chosen, node, env: framework.environment, logs: paths.logs };
|
|
294
|
+
const unitPath = systemd ? join(paths.systemd, `orionplace-${service}.service`) : join(paths.openrc, `orionplace-${service}`);
|
|
295
|
+
const unit = systemd ? systemdUnit(values) : openrcScript(values);
|
|
296
|
+
const conf = [
|
|
297
|
+
`# Managed by orionplace-${framework.id} install. Use "orionplace app" commands to change it.`,
|
|
298
|
+
`NAME=${service}`, `FRAMEWORK=${framework.id}`, `DIR=${root}`, `PORT=${chosen}`,
|
|
299
|
+
`USER=${account.name}`, `NODE=${node}`, `SERVICE=orionplace-${service}`, '',
|
|
300
|
+
].join('\n');
|
|
301
|
+
if (dryRun) {
|
|
302
|
+
return { dryRun, name: service, port: chosen, user: account.name, unitPath, unit, conf, frontends: settings.frontends || [], backend: settings.backend };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
log(`\n==> Building the ${framework.title} storefront ${service}`);
|
|
306
|
+
// The website build; ORIONPLACE_APP_ORIGIN would make the Android app build.
|
|
307
|
+
const buildEnv = { ...process.env };
|
|
308
|
+
delete buildEnv.ORIONPLACE_APP_ORIGIN;
|
|
309
|
+
if (!existsSync(join(root, 'node_modules', framework.dependency))) {
|
|
310
|
+
const installed = await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: root, env: buildEnv });
|
|
311
|
+
if (installed.code) throw new Error('npm install failed; see the messages above.');
|
|
312
|
+
}
|
|
313
|
+
if (build || !existsSync(join(root, framework.output))) {
|
|
314
|
+
const built = await run('npm', ['run', 'build'], { cwd: root, env: buildEnv });
|
|
315
|
+
if (built.code) throw new Error('The build failed; see the messages above. The running service (if any) was not changed.');
|
|
316
|
+
}
|
|
317
|
+
if (!existsSync(join(root, framework.output))) throw new Error(`The build did not produce ${framework.output}.`);
|
|
318
|
+
const owned = await run('chown', ['-R', `${account.uid}:${account.gid}`, root], { capture: true });
|
|
319
|
+
if (owned.code) throw new Error(`Could not give ${root} to ${account.name}:\n${owned.output}`);
|
|
320
|
+
|
|
321
|
+
log(`\n==> Installing the service orionplace-${service} on port ${chosen}`);
|
|
322
|
+
await writeAtomic(unitPath, unit, systemd ? 0o644 : 0o755);
|
|
323
|
+
await writeAtomic(join(paths.etc, 'apps', `${service}.conf`), conf);
|
|
324
|
+
const steps = systemd
|
|
325
|
+
? [['systemctl', ['daemon-reload']], ['systemctl', ['enable', `orionplace-${service}`]], ['systemctl', ['restart', `orionplace-${service}`]]]
|
|
326
|
+
: [['rc-update', ['add', `orionplace-${service}`, 'default']], ['rc-service', [`orionplace-${service}`, 'restart']]];
|
|
327
|
+
for (const [command, args] of steps) {
|
|
328
|
+
const result = await run(command, args, { capture: true });
|
|
329
|
+
if (result.code) throw new Error(`${command} ${args.join(' ')} failed:\n${result.output}`);
|
|
330
|
+
}
|
|
331
|
+
const status = await waitForHttp(chosen, settings.frontends[0]);
|
|
332
|
+
if (!status) {
|
|
333
|
+
const logs = systemd
|
|
334
|
+
? await run('journalctl', ['-u', `orionplace-${service}`, '-n', '40', '--no-pager'], { capture: true })
|
|
335
|
+
: await run('tail', ['-n', '40', join(paths.logs, `${service}.log`)], { capture: true });
|
|
336
|
+
throw new Error(`The storefront did not start on port ${chosen}. Last log lines:\n${logs.output}`);
|
|
337
|
+
}
|
|
338
|
+
log(` ✓ orionplace-${service} answers on 127.0.0.1:${chosen} (HTTP ${status})`);
|
|
339
|
+
|
|
340
|
+
const tool = paths.tool.find(path => existsSync(path));
|
|
341
|
+
const failed = [];
|
|
342
|
+
if (domains && tool) {
|
|
343
|
+
for (const domain of settings.frontends) {
|
|
344
|
+
log(`\n==> Connecting ${domain}`);
|
|
345
|
+
const backend = settings.backend.replace(/^https:\/\//, '').replace(/\/api$/, '');
|
|
346
|
+
const result = await run(tool, ['domain', 'add', domain, '--app', service, '--backend', backend, '--yes']);
|
|
347
|
+
if (result.code) failed.push(domain);
|
|
348
|
+
}
|
|
349
|
+
} else if (domains) {
|
|
350
|
+
log('\nThe orionplace server tool is not set up, so nginx and HTTPS were not configured. Prepare the server:');
|
|
351
|
+
log(' curl -fsSLo orionplace.sh https://orionmarket.place/downloads/orionplace.sh && sudo sh orionplace.sh setup');
|
|
352
|
+
log(`Then run this install again. Or proxy your HTTPS site to http://127.0.0.1:${chosen} as orionplace/README.md describes.`);
|
|
353
|
+
}
|
|
354
|
+
return { name: service, port: chosen, user: account.name, unitPath, frontends: settings.frontends, failed, connected: domains && Boolean(tool) };
|
|
355
|
+
}
|
package/src/project-readme.mjs
CHANGED
|
@@ -346,6 +346,13 @@ If installation fails after source generation, run \`npm install\` and retry.
|
|
|
346
346
|
|
|
347
347
|
## Deploy on your VPS
|
|
348
348
|
|
|
349
|
+
The quickest way: prepare the server with the OrionMarketplace server script,
|
|
350
|
+
then run \`sudo npx orionplace-react install\` in this folder. It builds the
|
|
351
|
+
project, runs it as a system service and connects your domains with nginx and
|
|
352
|
+
HTTPS. Steps: [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront?framework=react).
|
|
353
|
+
|
|
354
|
+
To set the server up yourself instead:
|
|
355
|
+
|
|
349
356
|
This integration needs a Node.js server and its domain configuration files.
|
|
350
357
|
It does not support static export or edge-only hosting. Deploy the build,
|
|
351
358
|
dependencies, \`sitemaps\`, and your environment configuration together; use an
|