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.
Files changed (3) hide show
  1. package/README.md +78 -17
  2. package/dist/main.mjs +237 -68
  3. 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
- npx create-shopnex
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. Ctrl+C stops everything.
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
- | `--data <folder>` | Where the database lives. Default `~/.shopnex/data`: the same folder is the same shop |
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
- | `--app <name>` | What to start. Only `shop` today; a picker appears once there is more than one |
22
- | `--verbose` | Show the servers' output while they start (otherwise only if one fails) |
23
- | `--dry-run` | Show what would run, and exit |
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 data folder (and the `<data folder>.secret` file next to it).
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
- `SHOPNEX_SECRET` is generated once and kept in `<data folder>.secret` (readable by you only), so the same shop keeps its
41
- sessions and stored keys. `SHOPNEX_BUNDLE_URL` downloads from somewhere else (a mirror; the `.sha256` sits next to it)
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.0.0-edge.init", engines: { node: ">=22.22.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 readFileSync3 } from "node:fs";
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: false }));
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
- data: values.data,
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 as join2 } from "node:path";
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, dataDir, port, storefrontPort, secret } = input;
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: join2(root, "admin"),
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(dataDir),
783
- SHOPNEX_MIGRATIONS_DIR: join2(root, "migrations"),
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: join2(root, "storefront"),
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 join3 } from "node:path";
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) => join3(appCacheDir(), key);
861
- var isInstalled = (key) => existsSync2(join3(bundleDir(key), "manifest.json"));
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(join3(appCacheDir(), `${key}.tmp-`));
926
- const file = join3(work, "bundle.tgz");
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 = join3(work, "app");
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 = join3(unpacked, "manifest.json");
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((resolve2) => setTimeout(resolve2, ms));
1136
+ var sleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
992
1137
  function portInUse(port) {
993
- const tryHost = (host) => new Promise((resolve2) => {
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(), resolve2(true)));
997
- socket.once("timeout", () => (socket.destroy(), resolve2(false)));
998
- socket.once("error", () => resolve2(false));
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((resolve2) => {
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(() => resolve2(code), 300);
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(readFileSync3(new URL("../package.json", import.meta.url), "utf8"));
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({ dataDir, plan, source }) {
1126
- return [
1127
- `Source ${source}`,
1128
- `Data ${dataDir}`,
1129
- `Admin ${plan.appUrl}/admin`,
1130
- `Storefront ${plan.storefrontUrl}`
1131
- ].join("\n");
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 dataDir = resolveDataDir(options2.data);
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: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
1312
+ const app = await pickApp(options2.app, { interactive });
1148
1313
  const plan = app.plan({
1149
1314
  root,
1150
- dataDir,
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: options2.dryRun ? "" : ensureSecret(dataDir)
1321
+ secret: shopEnv.secret,
1322
+ appKeys: loadAppKeys(shopDir, extras2, { write: !options2.dryRun })
1154
1323
  });
1155
- le(describe({ dataDir, plan, source }), app.label);
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.0.0-edge.init",
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",