create-shopnex 0.0.0-edge.init → 0.15.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 +78 -17
- package/dist/main.mjs +237 -68
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,26 +3,88 @@
|
|
|
3
3
|
Try ShopNex on your machine with one command. It starts the admin (with the API and hosted checkout) and the
|
|
4
4
|
storefront on an embedded database ([PGlite](https://pglite.dev)): no PostgreSQL, no Docker, no clone.
|
|
5
5
|
|
|
6
|
+
## Run it
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npx create-shopnex@latest
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
This creates the folder `shopnex` in the current folder and keeps the shop's database in it. Want another name?
|
|
13
|
+
Add it: `npx create-shopnex@latest my-shop`.
|
|
14
|
+
|
|
15
|
+
npm asks once whether to install the package: answer `y`. `@latest` makes npx fetch the newest version instead of
|
|
16
|
+
reusing an older copy from its cache. With pnpm, which does not ask:
|
|
17
|
+
|
|
6
18
|
```sh
|
|
7
|
-
|
|
19
|
+
pnpm create shopnex@latest
|
|
8
20
|
```
|
|
9
21
|
|
|
10
|
-
The first run downloads ShopNex (one file, about 15 MB) into `~/.shopnex/app/<version>` and checks its SHA-256; later
|
|
11
|
-
runs start straight away. It prints where to open the shop and the admin
|
|
22
|
+
The first run downloads ShopNex (one file, about 15 MB) into `~/.shopnex/app/<version>` (shared by all your shops) and checks its SHA-256; later
|
|
23
|
+
runs start straight away. It prints where to open the shop and the admin and opens the admin's setup wizard (use
|
|
24
|
+
`--no-open` to skip that). Ctrl+C stops everything. Nothing is installed globally.
|
|
25
|
+
|
|
26
|
+
## What it does, and why you can trust it
|
|
27
|
+
|
|
28
|
+
- **It runs on your machine only.** Both servers listen on `127.0.0.1`, and the data stays in the shop folder (`shopnex/data` by default).
|
|
29
|
+
- **It downloads one file.** `create-shopnex` itself fetches only the ShopNex build from this repository's GitHub
|
|
30
|
+
releases, and checks its SHA-256 before it unpacks anything. The checksum comes from the same place as the file, so it
|
|
31
|
+
catches a damaged or half-finished download, not a tampered release: the release itself is what you trust.
|
|
32
|
+
- **The source is here.** The package is
|
|
33
|
+
[`packages/create-shopnex`](https://github.com/shopnex-ai/shopnex/tree/main/packages/create-shopnex) in this
|
|
34
|
+
repository, and has no runtime dependencies: what you run is one bundled file built from that source.
|
|
35
|
+
- **It is built in the open.** Releases and edge builds are published from GitHub Actions
|
|
36
|
+
([release](https://github.com/shopnex-ai/shopnex/blob/main/.github/workflows/release.yaml),
|
|
37
|
+
[edge](https://github.com/shopnex-ai/shopnex/blob/main/.github/workflows/edge.yaml)) with npm provenance, so a
|
|
38
|
+
version's page on npmjs.com shows which commit and workflow run built it. The one exception is `0.0.0-edge.init`, the
|
|
39
|
+
first publish, made by hand to create the package. Publishing uses npm trusted publishing, so there is no long-lived
|
|
40
|
+
npm token.
|
|
12
41
|
|
|
13
42
|
## Options
|
|
14
43
|
|
|
15
|
-
| Option | What it does
|
|
16
|
-
| ----------------------- |
|
|
17
|
-
|
|
|
18
|
-
| `--port <n>` | Admin, API and checkout. Default `3000`
|
|
19
|
-
| `--storefront-port <n>` | Storefront. Default `4321`
|
|
20
|
-
| `--no-open` | Do not open the browser
|
|
21
|
-
| `--
|
|
22
|
-
| `--
|
|
23
|
-
| `--
|
|
44
|
+
| Option | What it does |
|
|
45
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
46
|
+
| `[name]` | The shop's folder, created if needed. `shopnex` when left out; the same folder is the same shop |
|
|
47
|
+
| `--port <n>` | Admin, API and checkout. Default `3000` |
|
|
48
|
+
| `--storefront-port <n>` | Storefront. Default `4321` |
|
|
49
|
+
| `--no-open` | Do not open the browser |
|
|
50
|
+
| `--with <apps>` | Also start optional apps next to the shop: `stripe`, `cj`, `shippo` (comma separated, or repeat the flag) |
|
|
51
|
+
| `--app <name>` | What to start. Only `shop` today; a picker appears once there is more than one |
|
|
52
|
+
| `--verbose` | Show the servers' output while they start (otherwise only if one fails) |
|
|
53
|
+
| `--dry-run` | Show what would run, and exit |
|
|
54
|
+
|
|
55
|
+
## Apps: Stripe, CJ Dropshipping and Shippo
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npx create-shopnex@latest --with stripe,cj,shippo
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each app runs as its own small server on this machine only (Stripe on port 4600, CJ Dropshipping on 4700, Shippo on 4900) and keeps
|
|
62
|
+
its data in `<shop folder>/apps/`. The admin finds them by itself; install them from the admin like any app and enter
|
|
63
|
+
your keys there. Stripe cannot reach a `localhost` address, so card payments need a public address for the app or
|
|
64
|
+
`stripe listen`: see the Stripe app's settings page.
|
|
65
|
+
|
|
66
|
+
## The shop's `.env`
|
|
67
|
+
|
|
68
|
+
The first run writes `<name>/.env` (readable by you only) with the two settings a shop needs, and every run reads it:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
DATABASE_URL=pglite:./.pglite # embedded database in the shop folder (a relative path is relative to it)
|
|
72
|
+
SHOPNEX_SECRET=<generated once> # encrypts the provider keys saved in the admin and signs sessions: keep it
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **PostgreSQL:** set `DATABASE_URL=postgres://user:password@host:5432/shopnex` and start again. The migrations are applied
|
|
76
|
+
on start (`SHOPNEX_AUTO_MIGRATE`). The data in `.pglite/` is not moved (there is no tool for that yet).
|
|
77
|
+
- **Other settings** in the file (SMTP, and so on) are passed to the admin. The address, the ports and the build's own
|
|
78
|
+
files are set by the CLI and its flags and win over the file.
|
|
79
|
+
- Do not commit or share it. Without `SHOPNEX_SECRET` the saved provider keys cannot be read: a lost one means a new
|
|
80
|
+
shop.
|
|
81
|
+
|
|
82
|
+
The first run also writes a `.gitignore` (keeps `.env`, `.pglite/` and the apps' keys out of git) and the
|
|
83
|
+
`docker-compose.yml` (fetched from the repository, the file the [self-hosting docs](https://shopnex.ai/docs/self-hosting/docker) describe: ShopNex, the Stripe
|
|
84
|
+
app and PostgreSQL from prebuilt images), for when the shop should run in Docker instead. Existing files are never
|
|
85
|
+
overwritten.
|
|
24
86
|
|
|
25
|
-
To start over, delete the
|
|
87
|
+
To start over, delete the shop folder.
|
|
26
88
|
|
|
27
89
|
## Not for production
|
|
28
90
|
|
|
@@ -37,11 +99,10 @@ migrations: JavaScript and WebAssembly, so one file serves every OS and CPU. `cr
|
|
|
37
99
|
own version, so a version without a release says so. The checksum comes from the same place as the file: it catches a
|
|
38
100
|
damaged download, not a tampered release.
|
|
39
101
|
|
|
40
|
-
`
|
|
41
|
-
|
|
42
|
-
and `SHOPNEX_APP_DIR` changes where builds are unpacked.
|
|
102
|
+
`SHOPNEX_BUNDLE_URL` downloads from somewhere else (a mirror; the `.sha256` sits next to it) and `SHOPNEX_APP_DIR`
|
|
103
|
+
changes where builds are unpacked.
|
|
43
104
|
|
|
44
|
-
**Edge:** `npx create-shopnex@edge` runs the latest build of `main` (`.github/workflows/edge.yaml` replaces the file of
|
|
105
|
+
**Edge:** `npx create-shopnex@edge` (or `pnpm create shopnex@edge`) runs the latest build of `main` (`.github/workflows/edge.yaml` replaces the file of
|
|
45
106
|
the `edge` pre-release on every push). It downloads again only when `main` moved. It can be broken: use a release for
|
|
46
107
|
anything you want to keep.
|
|
47
108
|
|
package/dist/main.mjs
CHANGED
|
@@ -40,7 +40,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
40
40
|
var define_PKG_default;
|
|
41
41
|
var init_define_PKG = __esm({
|
|
42
42
|
"<define:__PKG__>"() {
|
|
43
|
-
define_PKG_default = { version: "0.
|
|
43
|
+
define_PKG_default = { version: "0.15.0", engines: { node: ">=22.22.0" } };
|
|
44
44
|
}
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -179,7 +179,7 @@ init_define_PKG();
|
|
|
179
179
|
|
|
180
180
|
// src/cli.ts
|
|
181
181
|
init_define_PKG();
|
|
182
|
-
import { readFileSync as
|
|
182
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
183
183
|
|
|
184
184
|
// ../../node_modules/.pnpm/@clack+prompts@0.7.0/node_modules/@clack/prompts/dist/index.mjs
|
|
185
185
|
init_define_PKG();
|
|
@@ -689,10 +689,10 @@ var UnavailableError = class extends Error {
|
|
|
689
689
|
var DEFAULT_PORT = 3e3;
|
|
690
690
|
var DEFAULT_STOREFRONT_PORT = 4321;
|
|
691
691
|
var options = {
|
|
692
|
-
data: { type: "string" },
|
|
693
692
|
port: { type: "string" },
|
|
694
693
|
"storefront-port": { type: "string" },
|
|
695
694
|
app: { type: "string" },
|
|
695
|
+
with: { type: "string", multiple: true },
|
|
696
696
|
verbose: { type: "boolean" },
|
|
697
697
|
"no-open": { type: "boolean" },
|
|
698
698
|
"dry-run": { type: "boolean" },
|
|
@@ -709,21 +709,24 @@ function toPort(name, value, fallback) {
|
|
|
709
709
|
}
|
|
710
710
|
function parse(argv) {
|
|
711
711
|
let values;
|
|
712
|
+
let positionals;
|
|
712
713
|
try {
|
|
713
714
|
;
|
|
714
|
-
({ values } = parseArgs({ args: argv, options, allowPositionals:
|
|
715
|
+
({ values, positionals } = parseArgs({ args: argv, options, allowPositionals: true }));
|
|
715
716
|
} catch (error) {
|
|
716
717
|
throw new UsageError(`${error.message}
|
|
717
718
|
Run create-shopnex --help for the options.`);
|
|
718
719
|
}
|
|
720
|
+
if (positionals.length > 1) throw new UsageError(`Give one shop name, got: ${positionals.join(" ")}`);
|
|
719
721
|
const port = toPort("port", values.port, DEFAULT_PORT);
|
|
720
722
|
const storefrontPort = toPort("storefront-port", values["storefront-port"], DEFAULT_STOREFRONT_PORT);
|
|
721
723
|
if (port === storefrontPort) throw new UsageError("--port and --storefront-port must be different");
|
|
722
724
|
return {
|
|
723
|
-
|
|
725
|
+
name: positionals[0],
|
|
724
726
|
port,
|
|
725
727
|
storefrontPort,
|
|
726
728
|
app: values.app,
|
|
729
|
+
with: values.with ?? [],
|
|
727
730
|
verbose: Boolean(values.verbose),
|
|
728
731
|
open: !values["no-open"],
|
|
729
732
|
dryRun: Boolean(values["dry-run"]),
|
|
@@ -737,34 +740,10 @@ init_define_PKG();
|
|
|
737
740
|
|
|
738
741
|
// src/plan.ts
|
|
739
742
|
init_define_PKG();
|
|
740
|
-
import { join
|
|
741
|
-
|
|
742
|
-
// src/paths.ts
|
|
743
|
-
init_define_PKG();
|
|
744
|
-
import { randomBytes } from "node:crypto";
|
|
745
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
746
|
-
import { homedir } from "node:os";
|
|
747
|
-
import { dirname, join, resolve } from "node:path";
|
|
748
|
-
var defaultDataDir = () => join(homedir(), ".shopnex", "data");
|
|
749
|
-
function resolveDataDir(option) {
|
|
750
|
-
if (!option) return defaultDataDir();
|
|
751
|
-
const expanded = option === "~" ? homedir() : option.startsWith("~/") ? join(homedir(), option.slice(2)) : option;
|
|
752
|
-
return resolve(expanded);
|
|
753
|
-
}
|
|
754
|
-
var databaseUrl = (dataDir) => `pglite:${dataDir}`;
|
|
755
|
-
var appCacheDir = () => process.env.SHOPNEX_APP_DIR ?? join(homedir(), ".shopnex", "app");
|
|
756
|
-
function ensureSecret(dataDir) {
|
|
757
|
-
const file = `${dataDir}.secret`;
|
|
758
|
-
if (!existsSync(file)) {
|
|
759
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
760
|
-
writeFileSync(file, randomBytes(32).toString("hex"), { mode: 384 });
|
|
761
|
-
}
|
|
762
|
-
return readFileSync(file, "utf8").trim();
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// src/plan.ts
|
|
743
|
+
import { join } from "node:path";
|
|
766
744
|
function planFromBundle(input) {
|
|
767
|
-
const { root,
|
|
745
|
+
const { root, shopDir, databaseUrl, port, storefrontPort, secret, env, extras: extras2, appKeys = {} } = input;
|
|
746
|
+
const extraUrl = (extra) => `http://localhost:${extra.port}`;
|
|
768
747
|
const appUrl = `http://localhost:${port}`;
|
|
769
748
|
return {
|
|
770
749
|
appUrl,
|
|
@@ -772,26 +751,32 @@ function planFromBundle(input) {
|
|
|
772
751
|
processes: [
|
|
773
752
|
{
|
|
774
753
|
name: "admin",
|
|
775
|
-
cwd:
|
|
754
|
+
cwd: join(root, "admin"),
|
|
776
755
|
command: process.execPath,
|
|
777
756
|
args: ["server.mjs"],
|
|
778
757
|
env: {
|
|
758
|
+
// The shop's .env first: what the CLI decides (address, ports, the build's files) wins over it
|
|
759
|
+
...env,
|
|
779
760
|
NODE_ENV: "production",
|
|
780
761
|
HOST: "127.0.0.1",
|
|
781
762
|
PORT: String(port),
|
|
782
|
-
DATABASE_URL: databaseUrl
|
|
783
|
-
SHOPNEX_MIGRATIONS_DIR:
|
|
763
|
+
DATABASE_URL: databaseUrl,
|
|
764
|
+
SHOPNEX_MIGRATIONS_DIR: join(root, "migrations"),
|
|
765
|
+
// PostgreSQL from the shop's .env is migrated at start, like the embedded database
|
|
766
|
+
SHOPNEX_AUTO_MIGRATE: "true",
|
|
784
767
|
SHOPNEX_SECRET: secret,
|
|
785
768
|
BETTER_AUTH_URL: appUrl,
|
|
786
769
|
APP_URL: appUrl,
|
|
787
770
|
// Where the admin finds the storefront (Themes, the theme editor) until a Storefront URL is saved in Settings
|
|
788
|
-
STOREFRONT_URL: `http://localhost:${storefrontPort}
|
|
771
|
+
STOREFRONT_URL: `http://localhost:${storefrontPort}`,
|
|
772
|
+
// The admin registers an optional app when its address is set (the same variable the app reads)
|
|
773
|
+
...Object.fromEntries(extras2.map((extra) => [extra.urlEnv, extraUrl(extra)]))
|
|
789
774
|
},
|
|
790
775
|
readyUrl: `http://127.0.0.1:${port}/api/v1/health`
|
|
791
776
|
},
|
|
792
777
|
{
|
|
793
778
|
name: "storefront",
|
|
794
|
-
cwd:
|
|
779
|
+
cwd: join(root, "storefront"),
|
|
795
780
|
command: process.execPath,
|
|
796
781
|
args: ["dist/server/entry.mjs"],
|
|
797
782
|
env: {
|
|
@@ -803,7 +788,27 @@ function planFromBundle(input) {
|
|
|
803
788
|
SHOPNEX_PUBLIC_URL: appUrl
|
|
804
789
|
},
|
|
805
790
|
readyUrl: `http://127.0.0.1:${storefrontPort}/`
|
|
806
|
-
}
|
|
791
|
+
},
|
|
792
|
+
...extras2.map((extra) => ({
|
|
793
|
+
name: extra.name,
|
|
794
|
+
cwd: join(root, "apps", extra.dir),
|
|
795
|
+
command: process.execPath,
|
|
796
|
+
args: ["server.mjs"],
|
|
797
|
+
env: {
|
|
798
|
+
NODE_ENV: "production",
|
|
799
|
+
HOST: "127.0.0.1",
|
|
800
|
+
PORT: String(extra.port),
|
|
801
|
+
[extra.urlEnv]: extraUrl(extra),
|
|
802
|
+
SHOPNEX_INTERNAL_URL: appUrl,
|
|
803
|
+
// Its identity, kept with the shop so the same shop stays connected
|
|
804
|
+
[extra.keyEnv]: appKeys[extra.name] ?? "",
|
|
805
|
+
// Tables of its own: a schema in the shop's PostgreSQL, or its own embedded database
|
|
806
|
+
...extra.database && {
|
|
807
|
+
APP_DATABASE_URL: databaseUrl.startsWith("pglite:") ? `pglite:${join(shopDir, "apps", extra.dir, ".pglite")}` : databaseUrl
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
readyUrl: `http://127.0.0.1:${extra.port}/shopnex/app.js`
|
|
811
|
+
}))
|
|
807
812
|
]
|
|
808
813
|
};
|
|
809
814
|
}
|
|
@@ -837,6 +842,57 @@ async function pickApp(name, { interactive, list = apps }) {
|
|
|
837
842
|
return list.find((app) => app.name === answer) ?? first;
|
|
838
843
|
}
|
|
839
844
|
|
|
845
|
+
// src/app-keys.ts
|
|
846
|
+
init_define_PKG();
|
|
847
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
848
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
849
|
+
import { dirname, join as join2 } from "node:path";
|
|
850
|
+
var newAppKey = () => `snxk_${generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "der" }).toString("base64url")}`;
|
|
851
|
+
function loadAppKeys(shopDir, extras2, { write }) {
|
|
852
|
+
const keys = {};
|
|
853
|
+
for (const extra of extras2) {
|
|
854
|
+
const file = join2(shopDir, "apps", extra.dir, "app-key");
|
|
855
|
+
if (!existsSync(file)) {
|
|
856
|
+
if (!write) {
|
|
857
|
+
keys[extra.name] = "";
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
861
|
+
writeFileSync(file, newAppKey(), { mode: 384, flag: "wx" });
|
|
862
|
+
}
|
|
863
|
+
chmodSync(file, 384);
|
|
864
|
+
keys[extra.name] = readFileSync(file, "utf8").trim();
|
|
865
|
+
}
|
|
866
|
+
return keys;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/extras.ts
|
|
870
|
+
init_define_PKG();
|
|
871
|
+
var extras = [
|
|
872
|
+
{ name: "stripe", label: "Stripe", dir: "stripe", port: 4600, urlEnv: "STRIPE_APP_URL", keyEnv: "STRIPE_APP_KEY" },
|
|
873
|
+
{
|
|
874
|
+
name: "cj",
|
|
875
|
+
label: "CJ Dropshipping",
|
|
876
|
+
dir: "cj-dropshipping",
|
|
877
|
+
port: 4700,
|
|
878
|
+
urlEnv: "CJ_DROPSHIPPING_APP_URL",
|
|
879
|
+
keyEnv: "CJ_DROPSHIPPING_APP_KEY",
|
|
880
|
+
database: true
|
|
881
|
+
},
|
|
882
|
+
{ name: "shippo", label: "Shippo", dir: "shippo", port: 4900, urlEnv: "SHIPPO_APP_URL", keyEnv: "SHIPPO_APP_KEY" }
|
|
883
|
+
];
|
|
884
|
+
function pickExtras(values, list = extras) {
|
|
885
|
+
const names = (values ?? []).flatMap((value) => value.split(",")).map((name) => name.trim().toLowerCase());
|
|
886
|
+
const picked = [];
|
|
887
|
+
for (const name of names.filter(Boolean)) {
|
|
888
|
+
const found = list.find((extra) => extra.name === name);
|
|
889
|
+
if (!found)
|
|
890
|
+
throw new UsageError(`Unknown app "${name}" for --with. Available: ${list.map((e2) => e2.name).join(", ")}.`);
|
|
891
|
+
if (!picked.includes(found)) picked.push(found);
|
|
892
|
+
}
|
|
893
|
+
return picked;
|
|
894
|
+
}
|
|
895
|
+
|
|
840
896
|
// src/bundle.ts
|
|
841
897
|
init_define_PKG();
|
|
842
898
|
import { createHash } from "node:crypto";
|
|
@@ -850,15 +906,29 @@ import {
|
|
|
850
906
|
renameSync,
|
|
851
907
|
rmSync
|
|
852
908
|
} from "node:fs";
|
|
853
|
-
import { join as
|
|
909
|
+
import { join as join4 } from "node:path";
|
|
854
910
|
import { spawnSync } from "node:child_process";
|
|
855
911
|
import { Readable, Transform } from "node:stream";
|
|
856
912
|
import { pipeline } from "node:stream/promises";
|
|
913
|
+
|
|
914
|
+
// src/paths.ts
|
|
915
|
+
init_define_PKG();
|
|
916
|
+
import { homedir } from "node:os";
|
|
917
|
+
import { join as join3, resolve } from "node:path";
|
|
918
|
+
function resolveShopDir(name, cwd = process.cwd()) {
|
|
919
|
+
const trimmed = name.trim();
|
|
920
|
+
if (!trimmed) throw new UsageError("The shop name cannot be empty.");
|
|
921
|
+
const expanded = trimmed === "~" ? homedir() : trimmed.startsWith("~/") ? join3(homedir(), trimmed.slice(2)) : trimmed;
|
|
922
|
+
return resolve(cwd, expanded);
|
|
923
|
+
}
|
|
924
|
+
var appCacheDir = () => process.env.SHOPNEX_APP_DIR ?? join3(homedir(), ".shopnex", "app");
|
|
925
|
+
|
|
926
|
+
// src/bundle.ts
|
|
857
927
|
var isEdge = (version) => /-edge\./.test(version);
|
|
858
928
|
var bundleName = (version) => `shopnex-v${version}-node.tgz`;
|
|
859
929
|
var bundleUrl = (version) => process.env.SHOPNEX_BUNDLE_URL ?? (isEdge(version) ? "https://github.com/shopnex-ai/shopnex/releases/download/edge/shopnex-edge-node.tgz" : `https://github.com/shopnex-ai/shopnex/releases/download/v${version}/${bundleName(version)}`);
|
|
860
|
-
var bundleDir = (key) =>
|
|
861
|
-
var isInstalled = (key) => existsSync2(
|
|
930
|
+
var bundleDir = (key) => join4(appCacheDir(), key);
|
|
931
|
+
var isInstalled = (key) => existsSync2(join4(bundleDir(key), "manifest.json"));
|
|
862
932
|
var installedEdges = () => existsSync2(appCacheDir()) ? readdirSync(appCacheDir()).filter((name) => /^edge-[0-9a-f]{12}$/.test(name)) : [];
|
|
863
933
|
async function get(url, what, version) {
|
|
864
934
|
let response;
|
|
@@ -922,8 +992,8 @@ async function resolveBundle({ version }) {
|
|
|
922
992
|
}
|
|
923
993
|
const label = edge ? "the latest edge build" : `ShopNex ${version}`;
|
|
924
994
|
say(`Downloading ${label}`);
|
|
925
|
-
work = mkdtempSync(
|
|
926
|
-
const file =
|
|
995
|
+
work = mkdtempSync(join4(appCacheDir(), `${key}.tmp-`));
|
|
996
|
+
const file = join4(work, "bundle.tgz");
|
|
927
997
|
const actual = await download(
|
|
928
998
|
await get(url, "ShopNex", version),
|
|
929
999
|
file,
|
|
@@ -935,13 +1005,13 @@ async function resolveBundle({ version }) {
|
|
|
935
1005
|
);
|
|
936
1006
|
}
|
|
937
1007
|
say("Unpacking");
|
|
938
|
-
const unpacked =
|
|
1008
|
+
const unpacked = join4(work, "app");
|
|
939
1009
|
mkdirSync2(unpacked);
|
|
940
1010
|
const tar = spawnSync("tar", ["-xzf", file, "-C", unpacked], { encoding: "utf8" });
|
|
941
1011
|
if (tar.error || tar.status !== 0) {
|
|
942
1012
|
throw new UnavailableError(`Could not unpack the download with tar: ${tar.error?.message ?? tar.stderr.trim()}`);
|
|
943
1013
|
}
|
|
944
|
-
const manifestFile =
|
|
1014
|
+
const manifestFile = join4(unpacked, "manifest.json");
|
|
945
1015
|
const manifest = existsSync2(manifestFile) ? JSON.parse(readFileSync2(manifestFile, "utf8")) : void 0;
|
|
946
1016
|
if (edge ? !manifest : manifest?.version !== version) {
|
|
947
1017
|
throw new UnavailableError(
|
|
@@ -974,6 +1044,81 @@ function assertNode(version, enginesRange) {
|
|
|
974
1044
|
}
|
|
975
1045
|
}
|
|
976
1046
|
|
|
1047
|
+
// src/env.ts
|
|
1048
|
+
init_define_PKG();
|
|
1049
|
+
import { randomBytes } from "node:crypto";
|
|
1050
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, appendFileSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1051
|
+
import { isAbsolute, join as join5, resolve as resolve2 } from "node:path";
|
|
1052
|
+
import { parseEnv } from "node:util";
|
|
1053
|
+
var DEFAULT_DATABASE_URL = "pglite:./.pglite";
|
|
1054
|
+
var template = (secret) => `# ShopNex: the settings of this shop. Edit, then start it again.
|
|
1055
|
+
#
|
|
1056
|
+
# Where the data lives. The default is an embedded database in ./.pglite (PGlite): one process owns it, for trying
|
|
1057
|
+
# ShopNex out. For a real database use PostgreSQL, for example:
|
|
1058
|
+
# DATABASE_URL=postgres://user:password@localhost:5432/shopnex
|
|
1059
|
+
# (docker-compose.yml runs ShopNex and PostgreSQL in Docker)
|
|
1060
|
+
DATABASE_URL=${DEFAULT_DATABASE_URL}
|
|
1061
|
+
|
|
1062
|
+
# Encrypts the provider keys saved in the admin and signs sessions. Keep it: a new one logs everyone out and makes the
|
|
1063
|
+
# saved keys unreadable. Do not share this file.
|
|
1064
|
+
SHOPNEX_SECRET=${secret}
|
|
1065
|
+
`;
|
|
1066
|
+
var resolveDatabaseUrl = (url, shopDir) => {
|
|
1067
|
+
if (!url.startsWith("pglite:")) return url;
|
|
1068
|
+
const path = url.slice("pglite:".length);
|
|
1069
|
+
return isAbsolute(path) ? url : `pglite:${resolve2(shopDir, path)}`;
|
|
1070
|
+
};
|
|
1071
|
+
var redactUrl = (url) => url.replace(/\/\/([^:@/]+):[^@/]*@/, "//$1:***@");
|
|
1072
|
+
function loadShopEnv(shopDir, { write }) {
|
|
1073
|
+
const file = join5(shopDir, ".env");
|
|
1074
|
+
const exists = existsSync3(file);
|
|
1075
|
+
const { DATABASE_URL, SHOPNEX_SECRET, ...rest } = exists ? parseEnv(readFileSync3(file, "utf8")) : {};
|
|
1076
|
+
const secret = SHOPNEX_SECRET?.trim() || (write ? randomBytes(32).toString("hex") : "");
|
|
1077
|
+
const databaseUrl = DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
|
|
1078
|
+
if (write) {
|
|
1079
|
+
if (!exists) {
|
|
1080
|
+
mkdirSync3(shopDir, { recursive: true });
|
|
1081
|
+
writeFileSync2(file, template(secret), { mode: 384 });
|
|
1082
|
+
} else {
|
|
1083
|
+
const missing = [
|
|
1084
|
+
DATABASE_URL?.trim() ? "" : `DATABASE_URL=${DEFAULT_DATABASE_URL}`,
|
|
1085
|
+
SHOPNEX_SECRET?.trim() ? "" : `SHOPNEX_SECRET=${secret}`
|
|
1086
|
+
].filter(Boolean);
|
|
1087
|
+
if (missing.length)
|
|
1088
|
+
appendFileSync(file, `${readFileSync3(file, "utf8").endsWith("\n") ? "" : "\n"}${missing.join("\n")}
|
|
1089
|
+
`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return { databaseUrl: resolveDatabaseUrl(databaseUrl, shopDir), secret, rest, file };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// src/scaffold.ts
|
|
1096
|
+
init_define_PKG();
|
|
1097
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1098
|
+
import { join as join6 } from "node:path";
|
|
1099
|
+
var GITIGNORE = `# ShopNex: secrets and local data stay out of git
|
|
1100
|
+
.env
|
|
1101
|
+
.pglite/
|
|
1102
|
+
apps/*/.pglite/
|
|
1103
|
+
apps/*/app-key
|
|
1104
|
+
`;
|
|
1105
|
+
var composeUrl = (version) => `https://raw.githubusercontent.com/shopnex-ai/shopnex/${isEdge(version) ? "main" : `v${version}`}/deploy/docker-compose.yml`;
|
|
1106
|
+
async function writeScaffold(shopDir, version, fetcher = fetch) {
|
|
1107
|
+
mkdirSync4(shopDir, { recursive: true });
|
|
1108
|
+
const gitignore = join6(shopDir, ".gitignore");
|
|
1109
|
+
if (!existsSync4(gitignore)) writeFileSync3(gitignore, GITIGNORE);
|
|
1110
|
+
const compose = join6(shopDir, "docker-compose.yml");
|
|
1111
|
+
if (existsSync4(compose)) return;
|
|
1112
|
+
const url = composeUrl(version);
|
|
1113
|
+
try {
|
|
1114
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(5e3) });
|
|
1115
|
+
if (!response.ok) return `docker-compose.yml was not added: ${url} answered ${response.status}`;
|
|
1116
|
+
writeFileSync3(compose, await response.text());
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
return `docker-compose.yml was not added: ${url} could not be fetched (${error instanceof Error ? error.message : error})`;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
977
1122
|
// src/theme.ts
|
|
978
1123
|
init_define_PKG();
|
|
979
1124
|
var import_picocolors2 = __toESM(require_picocolors(), 1);
|
|
@@ -988,14 +1133,14 @@ init_define_PKG();
|
|
|
988
1133
|
import { spawn } from "node:child_process";
|
|
989
1134
|
import { connect } from "node:net";
|
|
990
1135
|
var import_picocolors3 = __toESM(require_picocolors(), 1);
|
|
991
|
-
var sleep = (ms) => new Promise((
|
|
1136
|
+
var sleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
992
1137
|
function portInUse(port) {
|
|
993
|
-
const tryHost = (host) => new Promise((
|
|
1138
|
+
const tryHost = (host) => new Promise((resolve3) => {
|
|
994
1139
|
const socket = connect({ port, host });
|
|
995
1140
|
socket.setTimeout(500);
|
|
996
|
-
socket.once("connect", () => (socket.destroy(),
|
|
997
|
-
socket.once("timeout", () => (socket.destroy(),
|
|
998
|
-
socket.once("error", () =>
|
|
1141
|
+
socket.once("connect", () => (socket.destroy(), resolve3(true)));
|
|
1142
|
+
socket.once("timeout", () => (socket.destroy(), resolve3(false)));
|
|
1143
|
+
socket.once("error", () => resolve3(false));
|
|
999
1144
|
});
|
|
1000
1145
|
return Promise.all([tryHost("127.0.0.1"), tryHost("::1")]).then((used) => used.some(Boolean));
|
|
1001
1146
|
}
|
|
@@ -1021,7 +1166,7 @@ function openBrowser(url) {
|
|
|
1021
1166
|
child.unref();
|
|
1022
1167
|
}
|
|
1023
1168
|
function runPlan(plan, { open, verbose }) {
|
|
1024
|
-
return new Promise((
|
|
1169
|
+
return new Promise((resolve3) => {
|
|
1025
1170
|
const children = [];
|
|
1026
1171
|
const tails = /* @__PURE__ */ new Map();
|
|
1027
1172
|
let stopping = false;
|
|
@@ -1039,7 +1184,7 @@ function runPlan(plan, { open, verbose }) {
|
|
|
1039
1184
|
} catch {
|
|
1040
1185
|
}
|
|
1041
1186
|
}
|
|
1042
|
-
setTimeout(() =>
|
|
1187
|
+
setTimeout(() => resolve3(code), 300);
|
|
1043
1188
|
};
|
|
1044
1189
|
for (const [index, proc] of plan.processes.entries()) {
|
|
1045
1190
|
const child = spawn(proc.command, proc.args, {
|
|
@@ -1102,57 +1247,81 @@ function runPlan(plan, { open, verbose }) {
|
|
|
1102
1247
|
}
|
|
1103
1248
|
|
|
1104
1249
|
// src/cli.ts
|
|
1105
|
-
var pkg = typeof define_PKG_default !== "undefined" ? define_PKG_default : JSON.parse(
|
|
1250
|
+
var pkg = typeof define_PKG_default !== "undefined" ? define_PKG_default : JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8"));
|
|
1106
1251
|
var HELP = `create-shopnex ${pkg.version}: try ShopNex on this machine with one command.
|
|
1107
1252
|
|
|
1108
1253
|
Usage
|
|
1109
|
-
create-shopnex [options]
|
|
1254
|
+
create-shopnex [name] [options] (name defaults to shopnex)
|
|
1110
1255
|
|
|
1256
|
+
Creates the folder <name> (shopnex when you leave it out) with the shop's settings (.env) and, by default, its
|
|
1257
|
+
database. Change DATABASE_URL in the .env to use PostgreSQL.
|
|
1111
1258
|
Starts the admin (with the API and checkout) and the storefront on an embedded database. Nothing else to install.
|
|
1112
1259
|
This is for trying ShopNex out: one process owns the data. For a real shop use Docker or PostgreSQL.
|
|
1113
1260
|
|
|
1114
1261
|
Options
|
|
1115
|
-
--data <folder> where the database lives (default ~/.shopnex/data); the same folder is the same shop
|
|
1116
1262
|
--port <n> admin, API and checkout (default 3000)
|
|
1117
1263
|
--storefront-port <n> storefront (default 4321)
|
|
1118
1264
|
--no-open do not open the browser
|
|
1265
|
+
--with <apps> also start optional apps: stripe, cj, shippo (comma separated, or repeat the flag)
|
|
1119
1266
|
--app <name> what to start (default shop; a picker appears once there is more than one)
|
|
1120
1267
|
--verbose show the output of the servers while they start
|
|
1121
1268
|
--dry-run show what would run and exit
|
|
1122
1269
|
-h, --help this text
|
|
1123
1270
|
-v, --version the version
|
|
1124
1271
|
`;
|
|
1125
|
-
function describe({
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1272
|
+
function describe({
|
|
1273
|
+
shopDir,
|
|
1274
|
+
database,
|
|
1275
|
+
plan,
|
|
1276
|
+
source,
|
|
1277
|
+
extras: extras2 = []
|
|
1278
|
+
}) {
|
|
1279
|
+
const rows = [
|
|
1280
|
+
["Source", source],
|
|
1281
|
+
["Folder", shopDir],
|
|
1282
|
+
["Database", redactUrl(database)],
|
|
1283
|
+
["Admin", `${plan.appUrl}/admin`],
|
|
1284
|
+
["Storefront", plan.storefrontUrl],
|
|
1285
|
+
...extras2.map((extra) => [extra.label, `http://localhost:${extra.port} (install it from the admin)`])
|
|
1286
|
+
];
|
|
1287
|
+
const width = Math.max(...rows.map(([label]) => label?.length ?? 0)) + 2;
|
|
1288
|
+
return rows.map(([label, value]) => `${label?.padEnd(width)}${value}`).join("\n");
|
|
1132
1289
|
}
|
|
1290
|
+
var DEFAULT_SHOP_NAME = "shopnex";
|
|
1133
1291
|
async function run(argv) {
|
|
1134
1292
|
const options2 = parse(argv);
|
|
1135
1293
|
if (options2.help) return void console.log(HELP);
|
|
1136
1294
|
if (options2.version) return void console.log(pkg.version);
|
|
1137
1295
|
assertNode(process.versions.node, pkg.engines.node);
|
|
1138
1296
|
oe(brandBadge(` create-shopnex ${pkg.version} `));
|
|
1139
|
-
const
|
|
1297
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1298
|
+
const name = options2.name ?? DEFAULT_SHOP_NAME;
|
|
1299
|
+
const shopDir = resolveShopDir(name);
|
|
1300
|
+
const extras2 = pickExtras(options2.with);
|
|
1301
|
+
const shopEnv = loadShopEnv(shopDir, { write: !options2.dryRun });
|
|
1302
|
+
const scaffoldWarning = options2.dryRun ? void 0 : await writeScaffold(shopDir, pkg.version);
|
|
1303
|
+
if (scaffoldWarning) f2.warn(scaffoldWarning);
|
|
1140
1304
|
const ports = [
|
|
1141
1305
|
{ port: options2.port, flag: "--port" },
|
|
1142
|
-
{ port: options2.storefrontPort, flag: "--storefront-port" }
|
|
1306
|
+
{ port: options2.storefrontPort, flag: "--storefront-port" },
|
|
1307
|
+
...extras2.map((extra) => ({ port: extra.port, flag: `--with (${extra.name} needs it free)` }))
|
|
1143
1308
|
];
|
|
1144
1309
|
if (!options2.dryRun) await assertPortsFree(ports);
|
|
1145
1310
|
const root = options2.dryRun ? bundleDir(pkg.version) : await resolveBundle({ version: pkg.version });
|
|
1146
1311
|
const source = isEdge(pkg.version) ? "ShopNex edge (the latest build from main, checked on every run)" : `ShopNex ${pkg.version} ${isInstalled(pkg.version) ? "(downloaded)" : "(downloaded on the first run)"}`;
|
|
1147
|
-
const app = await pickApp(options2.app, { interactive
|
|
1312
|
+
const app = await pickApp(options2.app, { interactive });
|
|
1148
1313
|
const plan = app.plan({
|
|
1149
1314
|
root,
|
|
1150
|
-
|
|
1315
|
+
shopDir,
|
|
1316
|
+
extras: extras2,
|
|
1317
|
+
databaseUrl: shopEnv.databaseUrl,
|
|
1318
|
+
env: shopEnv.rest,
|
|
1151
1319
|
port: options2.port,
|
|
1152
1320
|
storefrontPort: options2.storefrontPort,
|
|
1153
|
-
secret:
|
|
1321
|
+
secret: shopEnv.secret,
|
|
1322
|
+
appKeys: loadAppKeys(shopDir, extras2, { write: !options2.dryRun })
|
|
1154
1323
|
});
|
|
1155
|
-
le(describe({
|
|
1324
|
+
le(describe({ shopDir, database: shopEnv.databaseUrl, plan, source, extras: extras2 }), app.label);
|
|
1156
1325
|
if (options2.dryRun) return $e("Dry run: nothing was started.");
|
|
1157
1326
|
f2.warn("This is a trial database: it is not for production.");
|
|
1158
1327
|
process.exitCode = await runPlan(plan, { open: options2.open, verbose: options2.verbose });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-shopnex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Try ShopNex with one command: npx create-shopnex starts the admin and the storefront on an embedded database, nothing else to install",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://shopnex.ai",
|