orionplace-svelte 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/bin/orionplace-svelte.mjs +102 -0
  4. package/package.json +56 -0
  5. package/runtime/marketplace.d.mts +22 -0
  6. package/runtime/marketplace.mjs +211 -0
  7. package/src/project-readme.mjs +341 -0
  8. package/src/setup.mjs +261 -0
  9. package/src/update.mjs +36 -0
  10. package/src/validation.mjs +27 -0
  11. package/templates/src/app.d.ts +12 -0
  12. package/templates/src/app.html +11 -0
  13. package/templates/src/hooks.server.ts +14 -0
  14. package/templates/src/lib/interfaces/orionplace/apiBase/TMethod.ts +1 -0
  15. package/templates/src/lib/interfaces/orionplace/appBase/IAppLoad.ts +15 -0
  16. package/templates/src/lib/interfaces/orionplace/appBase/IAppLoadCommon.ts +41 -0
  17. package/templates/src/lib/interfaces/orionplace/appBase/IApploadUserProfile.ts +13 -0
  18. package/templates/src/lib/interfaces/orionplace/appBase/IForm.ts +21 -0
  19. package/templates/src/lib/interfaces/orionplace/appBase/ISEO.ts +21 -0
  20. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonBanner.ts +11 -0
  21. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonContact.ts +8 -0
  22. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonCurrency.ts +15 -0
  23. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonEvents.ts +18 -0
  24. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonLocale.ts +14 -0
  25. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonLogos.ts +6 -0
  26. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonPath.ts +22 -0
  27. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonSEO.ts +13 -0
  28. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonSocial.ts +15 -0
  29. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonTariff.ts +7 -0
  30. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonUnit.ts +12 -0
  31. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonVariables.ts +7 -0
  32. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonVisualSettings.ts +11 -0
  33. package/templates/src/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonWalletValue.ts +3 -0
  34. package/templates/src/lib/interfaces/orionplace/category/ICategory.ts +40 -0
  35. package/templates/src/lib/interfaces/orionplace/domains/IDomain.ts +13 -0
  36. package/templates/src/lib/interfaces/orionplace/index.ts +30 -0
  37. package/templates/src/lib/interfaces/orionplace/products/IProduct.ts +235 -0
  38. package/templates/src/lib/interfaces/orionplace/shared/IGeoCoordinates.ts +4 -0
  39. package/templates/src/lib/interfaces/orionplace/shared/ITransformValue.ts +3 -0
  40. package/templates/src/lib/interfaces/orionplace/state.ts +4 -0
  41. package/templates/src/lib/interfaces/orionplace/subscribeOptions/ISubscribeOption.ts +9 -0
  42. package/templates/src/lib/interfaces/orionplace/subscribeTariff/ISubscribeTariff.ts +46 -0
  43. package/templates/src/lib/interfaces/orionplace/subscribes/ISubscribe.ts +28 -0
  44. package/templates/src/lib/interfaces/orionplace/transport.ts +30 -0
  45. package/templates/src/lib/orionplace/appLoadClient.ts +33 -0
  46. package/templates/src/lib/orionplace/appLoadData.ts +31 -0
  47. package/templates/src/lib/orionplace/appLoadResult.ts +56 -0
  48. package/templates/src/lib/orionplace/appLoadServer.ts +93 -0
  49. package/templates/src/lib/orionplace/domain.ts +11 -0
  50. package/templates/src/lib/orionplace/store.svelte.ts +121 -0
  51. package/templates/src/routes/+layout.server.ts +11 -0
  52. package/templates/src/routes/+layout.svelte +19 -0
  53. package/templates/src/routes/+page.svelte +108 -0
  54. package/templates/src/routes/api/[...orionplace]/+server.ts +15 -0
  55. package/templates/svelte.config.js +13 -0
  56. package/templates/vite.config.ts +19 -0
@@ -0,0 +1,341 @@
1
+ export function projectReadme(settings) {
2
+ const frontend = settings.frontend || 'shop.example.com';
3
+ const backend = settings.backend || 'https://your-project.orionplace.app/api';
4
+ return `# Your Orionplace SvelteKit integration
5
+
6
+ This folder contains the integration source for your SvelteKit storefront.
7
+ Your personal backend API is provided in your account at https://orionmarket.place.
8
+ You can change that backend domain in your account, then update this project.
9
+
10
+ ## Quick start from zero
11
+
12
+ Install Node.js 20.19 or newer (including npm). Run these commands from the folder
13
+ where you want to create a new project; no existing SvelteKit project is needed:
14
+
15
+ \`\`\`bash
16
+ npx --yes orionplace-svelte init my-marketplace
17
+ cd my-marketplace
18
+ npm install
19
+ npx orionplace-svelte set backend your-project.orionplace.app
20
+ npx orionplace-svelte add frontend shop.example.com
21
+ npm run dev
22
+ \`\`\`
23
+
24
+ Replace \`your-project.orionplace.app\` with the personal backend domain shown in
25
+ your profile at [orionmarket.place](https://orionmarket.place), and
26
+ \`shop.example.com\` with your storefront domain. The first frontend also becomes
27
+ the local MIMIC domain.
28
+
29
+ Open the local URL printed by Vite (normally http://localhost:5173). The starter
30
+ should show \`Loaded\` in its AppLoad state section. If it shows \`Failed\`, check
31
+ the domain mapping and backend response before building pages.
32
+
33
+ This project is already generated. To configure or run it, use the following
34
+ sections from its root directory; do not create another project inside it.
35
+
36
+ ## Current setup
37
+
38
+ - Frontend domains: ${settings.frontends?.length ? settings.frontends.map(domain => '\`' + domain + '\`').join(', ') : 'not configured yet'}
39
+ - Local MIMIC domain: \`${settings.frontend || 'empty'}\`
40
+ - Personal API: \`${settings.backend || 'not configured yet'}\`
41
+
42
+ Set or change these values from the project root:
43
+
44
+ \`\`\`bash
45
+ npx orionplace-svelte set backend ${backend}
46
+ npx orionplace-svelte add frontend ${frontend}
47
+ npx orionplace-svelte add frontend www.${frontend}
48
+ npx orionplace-svelte remove frontend www.${frontend}
49
+ \`\`\`
50
+
51
+ Set the backend or add a frontend first; either order works. Each frontend has its
52
+ own \`sitemaps/<domain>.cfg\` mapping to your personal API. Adding a domain again
53
+ is safe. Changing the backend updates every CLI-managed frontend mapping.
54
+
55
+ MIMIC is empty in a new project: \`MIMIC_FRONT_DOMAIN=""\`. Setting the backend
56
+ alone leaves it empty. The first added frontend sets MIMIC; adding more domains
57
+ preserves that choice.
58
+
59
+ Use \`npx orionplace-svelte set mimic <domain>\` to select an already-added frontend
60
+ for localhost. It changes MIMIC without editing domain mappings. Add an unknown
61
+ domain with \`add frontend\` first. The legacy \`set frontend\` shortcut still
62
+ adds and selects a frontend.
63
+
64
+ \`\`\`bash
65
+ npx orionplace-svelte help
66
+ npx orionplace-svelte set mimic ${frontend}
67
+ \`\`\`
68
+
69
+ Removing a frontend deletes only its unchanged generated \`.cfg\` file. If it was
70
+ the selected MIMIC domain, the first remaining domain takes its place. Removing
71
+ the last frontend clears MIMIC; add a frontend again before making API requests.
72
+ Unknown or manually edited mappings are not removed automatically.
73
+
74
+ Restart a running development server after changing domains so Vite reloads its
75
+ allowed hosts and SvelteKit reloads \`.env\`. Production reads domain files on each
76
+ request; restart or reload the process environment when changing MIMIC. DNS, TLS
77
+ and hosting-proxy settings must also cover each public frontend domain.
78
+ Existing unrelated environment variables and SvelteKit configuration are preserved.
79
+
80
+ ## Files you can read and customize
81
+
82
+ | File | Purpose |
83
+ | --- | --- |
84
+ | \`runtime/marketplace.mjs\` | Domain lookup, configuration validation, API transport and cookies. |
85
+ | \`runtime/marketplace.d.mts\` | Types for the gateway functions used by your TypeScript source. |
86
+ | \`settings.json\` | The CLI's frontend list, local MIMIC domain and personal backend; not a secret store. |
87
+ | \`../src/routes/api/[...orionplace]/+server.ts\` | The route serving \`/api/...\` from your project. |
88
+ | \`../src/hooks.server.ts\` | Marks personalized pages private and varies them by storefront domain. |
89
+ | \`../svelte.config.js\` | Uses \`@sveltejs/adapter-node\`, which this integration requires. |
90
+ | \`../vite.config.ts\` | Allows your storefront domains during local development. |
91
+ | \`../sitemaps/${frontend}.cfg\` | The backend URL used for this frontend domain. |
92
+ | \`../.env\` | Local MIMIC setting; keep secrets out of version control. |
93
+
94
+ You can edit these files; subsequent setup runs preserve customized source and
95
+ this README. The CLI refuses to overwrite an unknown or manually changed domain
96
+ mapping, and never replaces your own configuration, layout, page or hooks. When it
97
+ keeps one of your files, it prints the path so you can merge the starter yourself.
98
+
99
+ ## How requests reach your personal API
100
+
101
+ The trusted hosting proxy supplies the public domain through \`X-Front-Domain\`,
102
+ \`X-Forwarded-Host\`, or \`Host\`, in that order. On localhost,
103
+ \`MIMIC_FRONT_DOMAIN\` supplies the domain instead. A known public domain takes
104
+ priority over MIMIC; an unconfigured public domain returns \`503\`.
105
+
106
+ The domain file is read for each request. The gateway preserves the request method,
107
+ query, body, authorization and cookies. Backend HTTP status codes are preserved.
108
+ Backend cookie domains are removed so cookies belong to the active frontend.
109
+ Responses are not cached by this gateway. Network failures return \`502\`.
110
+
111
+ The configuration directory defaults to \`sitemaps\` under the process working
112
+ directory. Set \`SITEMAPS_DIR\` only if you store it elsewhere. Nothing here sets
113
+ a fixed public site URL. Use \`getStorefrontDomain(event.request)\` from
114
+ \`src/lib/orionplace/domain.ts\` when you build canonical URLs or page metadata.
115
+
116
+ ## Start and verify
117
+
118
+ Use Node.js 20.19 or newer. After scaffolding a new project, install dependencies:
119
+
120
+ \`\`\`bash
121
+ npm install
122
+ npm run dev
123
+ \`\`\`
124
+
125
+ In another terminal:
126
+
127
+ \`\`\`bash
128
+ curl -i http://localhost:5173/api/appload \\
129
+ -H 'Content-Type: application/json' \\
130
+ --data '{"ignore_cache":true,"detalize_mode":"light"}'
131
+ \`\`\`
132
+
133
+ Check both the HTTP status and \`form_errors\`. Successful initialization returns
134
+ \`page_data\`; HTTP 200 by itself is insufficient. Documentation and public requests
135
+ do not require a login prompt. Protected actions still require backend permission.
136
+
137
+ ## AppLoad architecture
138
+
139
+ The root layout load initializes the marketplace before your page renders. The
140
+ request uses \`POST /api/appload\` with \`detalize_mode: "light"\` and
141
+ \`ignore_cache: true\`. Tenant settings may override these options.
142
+
143
+ | Project file | Responsibility |
144
+ | --- | --- |
145
+ | \`src/routes/+layout.server.ts\` | Awaits initialization and returns the snapshot to every route. |
146
+ | \`src/routes/+layout.svelte\` | Creates the store for this session and puts it in context. |
147
+ | \`src/lib/orionplace/appLoadServer.ts\` | Server transport, session forwarding and response-cookie relay. |
148
+ | \`src/lib/orionplace/appLoadClient.ts\` | Browser transport for explicit initialize and refresh. |
149
+ | \`src/lib/orionplace/appLoadResult.ts\` | HTTP, form-error and bootstrap-envelope checks. |
150
+ | \`src/lib/orionplace/appLoadData.ts\` | Discovery merging and snapshot status, without reactivity. |
151
+ | \`src/lib/orionplace/store.svelte.ts\` | The typed snapshot, status, errors and derived data as Svelte 5 runes. |
152
+ | \`src/lib/orionplace/domain.ts\` | The storefront domain selected for the current request. |
153
+ | \`src/lib/interfaces/orionplace/index.ts\` | Response envelope and all nested models. |
154
+
155
+ The server render owns initialization. Reading the store never starts a request,
156
+ and initialization never opens an authentication prompt.
157
+
158
+ ### Read data in a component
159
+
160
+ \`\`\`svelte
161
+ <script lang="ts">
162
+ import { getOrionplaceContext } from '$lib/orionplace/store.svelte';
163
+
164
+ const marketplace = getOrionplaceContext();
165
+ </script>
166
+
167
+ {#if marketplace.failure}
168
+ <p role="alert">Marketplace initialization failed.</p>
169
+ {:else if !marketplace.isReady}
170
+ <p role="status">AppLoad: {marketplace.status}</p>
171
+ {:else}
172
+ <p>{marketplace.products.length} products; {Object.keys(marketplace.currencies).length} currencies.</p>
173
+ {/if}
174
+ \`\`\`
175
+
176
+ The store is reactive: reading a property inside markup or an effect re-renders
177
+ that part when the data changes.
178
+
179
+ | Store property | Available data |
180
+ | --- | --- |
181
+ | \`result\` | Response envelope, HTTP status and load time. |
182
+ | \`data\` | Complete \`page_data\`, including optional sections. |
183
+ | \`init\` | All initialization settings, banners, events, contacts, SEO, units and other sections. |
184
+ | \`profile\`, \`isAuthenticated\` | Current profile and session state. |
185
+ | \`locales\`, \`currencies\`, \`catalog\`, \`products\`, \`tariffs\` | Common initial collections. |
186
+ | \`publicEndpoints\`, \`authenticatedEndpoints\`, \`endpoints\` | Current discovery groups and their combined view. |
187
+ | \`isReady\`, \`status\`, \`failure\` | Readiness, loading/success/error state and sanitized failure details. |
188
+
189
+ Use \`marketplace.getEndpoint(section, name)\` with section and endpoint keys from
190
+ the current response. It returns the endpoint's target, methods and forms, or
191
+ \`undefined\` when unavailable. Full descriptions may be omitted in light mode.
192
+ Discovery describes availability; the backend checks permissions on every call.
193
+
194
+ ### Read data in a server load
195
+
196
+ \`\`\`typescript
197
+ import type { PageServerLoad } from './$types';
198
+
199
+ export const load: PageServerLoad = async ({ parent }) => {
200
+ const { orionplace } = await parent();
201
+ const categories = orionplace.result?.response.page_data.init.catalog ?? [];
202
+ return { categories };
203
+ };
204
+ \`\`\`
205
+
206
+ The layout load already made the request; \`await parent()\` reuses its snapshot
207
+ instead of calling AppLoad again.
208
+
209
+ ### Refresh after a session change
210
+
211
+ \`\`\`svelte
212
+ <script lang="ts">
213
+ import { getOrionplaceContext } from '$lib/orionplace/store.svelte';
214
+
215
+ const marketplace = getOrionplaceContext();
216
+
217
+ async function afterSessionChanged() {
218
+ marketplace.reset();
219
+ await marketplace.refresh();
220
+ }
221
+ </script>
222
+
223
+ <button type="button" onclick={afterSessionChanged}>Reload marketplace</button>
224
+ \`\`\`
225
+
226
+ Call this after the session-changing API request has completed. Reset clears the
227
+ profile, initialization and discovery, aborts pending initialization and prevents
228
+ late responses restoring the old session. Refresh replaces the whole snapshot.
229
+ On failure, stale data and permissions remain cleared; the store exposes the error.
230
+ SvelteKit's \`invalidateAll()\` also works: the layout load runs again and the
231
+ store adopts the new server snapshot.
232
+
233
+ ### Session and CSRF cookies
234
+
235
+ Session and CSRF cookies travel in HTTP headers, not in the initialization JSON.
236
+ Keep the signed HttpOnly \`sessionid\` opaque: the browser sends it automatically.
237
+ Never log, decode, rewrite or put cookie values in the store or localStorage.
238
+ Read the current \`csrftoken\` for each protected browser action and send it as
239
+ \`X-CSRFToken\` through the same-origin \`/api/\` gateway with credentials enabled.
240
+ Login can rotate the token. The AppLoad loaders handle initialization; they are
241
+ not a general CSRF-aware client for other endpoints.
242
+
243
+ During a server render the layout load forwards the incoming cookies and relays
244
+ each backend \`Set-Cookie\` separately through SvelteKit's cookie API, preserving
245
+ the exact signed value and attributes. The gateway removed only the backend
246
+ \`Domain\`, so those cookies belong to your storefront. Cookies established this
247
+ way are also visible to later calls in the same render. Never share a cookie jar
248
+ across visitors.
249
+
250
+ Renew CSRF using \`POST /api/csrf_renew\` with \`{}\`, then re-read the cookie.
251
+ HTTP \`419\` with \`page_data.csrf: false\` means CSRF rejection; a general
252
+ \`403\` can mean permissions or origin rejection. Offer explicit retries for
253
+ mutations. After confirmed login/logout, reset and refresh AppLoad; these actions
254
+ change application data and do not log in/out or delete cookies.
255
+
256
+ [Cookie lifecycle and browser request example](https://orionmarket.place/sandbox/appload/svelte#session-cookies)
257
+
258
+ ### Rendering, navigation and failures
259
+
260
+ Each server render gets its own snapshot; the layout creates one store per browser
261
+ session and hydrates it, so hydration and client navigation do not repeat
262
+ initialization. Concurrent \`load\` calls share one request per store.
263
+
264
+ Failures also survive hydration and navigation. They never open a login modal or
265
+ trigger an automatic retry on every route. Show the error and offer explicit
266
+ reload. Feature components should check \`isReady\` before using required data.
267
+
268
+ Page responses use \`private, no-store\` through \`src/hooks.server.ts\`. Do not put
269
+ personalized HTML in shared caches or persist the snapshot in localStorage. The
270
+ debug page displays summaries; account details, keys and backend exception stacks
271
+ are not rendered there.
272
+
273
+ Interfaces cover initialization, discovery/forms, profile and nested models.
274
+ Plugin-defined configuration uses JSON types; interfaces are not full runtime
275
+ validation of every nested field. Existing edited generated source is preserved;
276
+ review and merge newer templates manually where the CLI reports preserved files.
277
+
278
+ ## Update the package and components
279
+
280
+ Run this from your project root whenever you want the latest release:
281
+
282
+ \`\`\`bash
283
+ npx orionplace-svelte update
284
+ \`\`\`
285
+
286
+ The command fetches the latest CLI, adds new template files, refreshes unchanged
287
+ generated source, updates the package dependency, and runs \`npm install\`.
288
+ It preserves edited files and lists them for manual review. Find their incoming
289
+ versions under \`node_modules/orionplace-svelte/templates\` or \`runtime\`.
290
+ Domain mappings, domain settings and \`.env\` are preserved. Upstream file removals
291
+ do not delete local source. Restart development or rebuild and redeploy afterward.
292
+
293
+ Use \`npx orionplace-svelte update --dry-run\` to review changes without modifying
294
+ project files or installed dependencies; downloading the CLI may update npm's
295
+ cache. The alias \`npm run orionplace:update\` performs the same normal update.
296
+ If installation fails after source generation, run \`npm install\` and retry.
297
+
298
+ ## Deploy on your VPS
299
+
300
+ This integration needs a Node.js server and its domain configuration files.
301
+ It uses \`@sveltejs/adapter-node\`; static and edge-only adapters cannot read
302
+ domain files. Deploy the build, dependencies, \`sitemaps\`, and your environment
303
+ configuration together; use an absolute \`SITEMAPS_DIR\` when running from another
304
+ working directory.
305
+
306
+ \`\`\`bash
307
+ npm run build
308
+ HOST=127.0.0.1 PORT=3000 node --env-file=.env build/index.js
309
+ \`\`\`
310
+
311
+ The production process explicitly reads \`MIMIC_FRONT_DOMAIN\`. A public domain
312
+ still takes precedence. The Node port must stay private behind your HTTPS proxy.
313
+ Set \`ORIGIN\`, or \`PROTOCOL_HEADER\` and \`HOST_HEADER\`, as the adapter's
314
+ documentation describes, so SvelteKit trusts your proxy's forwarded headers.
315
+
316
+ Inside the storefront's HTTPS Nginx server block:
317
+
318
+ \`\`\`nginx
319
+ location / {
320
+ proxy_pass http://127.0.0.1:3000;
321
+ proxy_http_version 1.1;
322
+ proxy_set_header Host $host;
323
+ proxy_set_header X-Forwarded-Host $host;
324
+ proxy_set_header X-Front-Domain $host;
325
+ proxy_set_header X-Forwarded-Proto $scheme;
326
+ proxy_set_header X-Init-Ip $remote_addr;
327
+ proxy_buffering off;
328
+ }
329
+ \`\`\`
330
+
331
+ Here localhost is your frontend process on your VPS. That process selects the
332
+ remote personal API from the domain file. The proxy must overwrite those headers.
333
+ An unknown domain never falls back to another tenant. This setup does not generate
334
+ sitemap XML or add analytics.
335
+
336
+ ## More documentation
337
+
338
+ - [SvelteKit package installation and domain setup](https://orionmarket.place/sandbox/new-project/svelte)
339
+ - [AppLoad](https://orionmarket.place/sandbox)
340
+ `;
341
+ }
package/src/setup.mjs ADDED
@@ -0,0 +1,261 @@
1
+ import { readFile, readdir, lstat, mkdir, writeFile, rename, unlink } from 'node:fs/promises';
2
+ import { resolve, dirname, relative, sep, posix } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { backendUrl, frontendDomain } from './validation.mjs';
5
+ import { projectReadme } from './project-readme.mjs';
6
+
7
+ const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
8
+ const hash = text => createHash('sha256').update(text).digest('hex');
9
+ // Generated files a customer may already own: keep theirs and report them instead of failing.
10
+ const optional = [
11
+ 'svelte.config.js', 'vite.config.ts', 'src/app.html', 'src/app.d.ts', 'src/hooks.server.ts',
12
+ 'src/routes/+layout.server.ts', 'src/routes/+layout.svelte', 'src/routes/+page.svelte',
13
+ ];
14
+ // One template covers each of these project files, whatever extension the project uses.
15
+ const equivalents = {
16
+ 'svelte.config.js': ['svelte.config.js', 'svelte.config.ts', 'svelte.config.mjs'],
17
+ 'vite.config.ts': ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'],
18
+ 'src/hooks.server.ts': ['src/hooks.server.ts', 'src/hooks.server.js'],
19
+ 'src/routes/+layout.server.ts': ['src/routes/+layout.server.ts', 'src/routes/+layout.server.js'],
20
+ };
21
+ const otherAdapter = /@sveltejs\/adapter-(auto|static|vercel|netlify|cloudflare|cloudflare-workers|deno)/;
22
+
23
+ async function read(path) {
24
+ try { return await readFile(path, 'utf8'); }
25
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
26
+ }
27
+ async function safePath(root, name) {
28
+ const target = resolve(root, name);
29
+ if (relative(root, target).startsWith('..') || target === root) throw new Error('Invalid generated path');
30
+ let cursor = root;
31
+ for (const piece of relative(root, target).split(sep)) {
32
+ cursor = resolve(cursor, piece);
33
+ try { if ((await lstat(cursor)).isSymbolicLink()) throw new Error(`Refusing symbolic link: ${name}`); }
34
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
35
+ }
36
+ return target;
37
+ }
38
+ async function directoryExists(root, name) {
39
+ try { return (await lstat(resolve(root, name))).isDirectory(); }
40
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
41
+ }
42
+ /** Generated source imports the editable gateway by an explicit relative path. */
43
+ function runtimeImport(name) {
44
+ const from = posix.dirname(name);
45
+ const path = posix.relative(from, 'orionplace/runtime/marketplace.mjs');
46
+ return path.startsWith('.') ? path : `./${path}`;
47
+ }
48
+ const tsconfigTemplate = {
49
+ extends: './.svelte-kit/tsconfig.json',
50
+ compilerOptions: {
51
+ allowJs: true, checkJs: false, esModuleInterop: true, forceConsistentCasingInFileNames: true,
52
+ resolveJsonModule: true, skipLibCheck: true, sourceMap: true, strict: true, moduleResolution: 'bundler',
53
+ },
54
+ };
55
+ /**
56
+ * SvelteKit needs no plugin registration: the gateway is a route in the project.
57
+ * Only a non-Node adapter or a conflicting /api rewrite must be resolved first.
58
+ */
59
+ function inspectSvelteConfig(source) {
60
+ const cleaned = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
61
+ if (otherAdapter.test(cleaned)) {
62
+ throw new Error('The marketplace gateway reads domain files from disk. Switch svelte.config.js to @sveltejs/adapter-node before setup. No files were changed.');
63
+ }
64
+ if (/\b(?:appDir|files\s*:)/.test(cleaned) && /routes\s*:/.test(cleaned)) {
65
+ throw new Error('Custom SvelteKit directories require manual integration. No files were changed.');
66
+ }
67
+ }
68
+ function updateEnvironment(source, domain) {
69
+ const lines = (source || '').split(/\r?\n/);
70
+ let updated = false;
71
+ const result = lines.flatMap(line => {
72
+ if (!/^\s*(?:export\s+)?MIMIC_FRONT_DOMAIN\s*=/.test(line)) return [line];
73
+ if (updated) return [];
74
+ updated = true;
75
+ return [`MIMIC_FRONT_DOMAIN="${domain}"`];
76
+ });
77
+ if (!updated) result.push(`MIMIC_FRONT_DOMAIN="${domain}"`);
78
+ return result.join('\n').replace(/^\n/, '').replace(/\n*$/, '\n');
79
+ }
80
+
81
+ export async function setupProject({ cwd = process.cwd(), action = 'set', field, value, dryRun = false } = {}) {
82
+ if (!['set', 'add', 'remove', 'update'].includes(action) ||
83
+ (['add', 'remove'].includes(action) && field !== 'frontend') || (action === 'update' && field)) {
84
+ throw new Error('Use set backend, add frontend, remove frontend, set mimic, or set frontend.');
85
+ }
86
+ const root = resolve(cwd);
87
+ const setting = field === 'backend' ? backendUrl(value) : ['frontend', 'mimic'].includes(field) ? frontendDomain(value) : null;
88
+ if (field && !setting) throw new Error('Choose backend, frontend, or mimic.');
89
+ const names = ['package.json', 'orionplace/settings.json', 'orionplace/.generated.json', '.env', '.gitignore',
90
+ ...equivalents['svelte.config.js'], ...equivalents['vite.config.ts']];
91
+ const current = {};
92
+ for (const name of names) current[name] = await read(await safePath(root, name));
93
+ const pkg = current['package.json'] ? JSON.parse(current['package.json']) : { name: 'orionplace-storefront', private: true, version: '0.1.0', type: 'module' };
94
+ const dependencies = { ...pkg.devDependencies, ...pkg.dependencies };
95
+ const kit = dependencies['@sveltejs/kit'];
96
+ if (action === 'update' && (!kit || !current['orionplace/settings.json'] || !current['orionplace/.generated.json'])) {
97
+ throw new Error('Update requires an initialized Orionplace Svelte project. Run init first.');
98
+ }
99
+ if (!kit && ['nuxt', 'next'].some(name => dependencies[name])) throw new Error('This package sets up SvelteKit projects only.');
100
+ if (kit && !/^(?:[~^]|>=)?2(?:\.|$)/.test(kit)) {
101
+ throw new Error('This release supports SvelteKit 2. Upgrade your @sveltejs/kit dependency before setup.');
102
+ }
103
+ if (kit && dependencies.svelte && !/^(?:[~^]|>=)?5(?:\.|$)/.test(dependencies.svelte)) {
104
+ throw new Error('The generated starter uses Svelte 5 runes. Upgrade your svelte dependency before setup.');
105
+ }
106
+ const configs = equivalents['svelte.config.js'].filter(name => current[name] !== null);
107
+ if (configs.length > 1) throw new Error('Multiple Svelte configuration files found; keep one root config.');
108
+ if (configs.length) inspectSvelteConfig(current[configs[0]]);
109
+ for (const name of Object.keys(dependencies)) {
110
+ if (otherAdapter.test(name)) {
111
+ throw new Error(`Remove ${name} and use @sveltejs/adapter-node: the gateway reads domain files from disk. No files were changed.`);
112
+ }
113
+ }
114
+ if (await directoryExists(root, 'src/routes/api')) {
115
+ const entries = await readdir(await safePath(root, 'src/routes/api'));
116
+ if (entries.some(name => name.startsWith('[...') && name !== '[...orionplace]')) {
117
+ throw new Error('Existing API catch-all in src/routes/api. Resolve this route conflict first.');
118
+ }
119
+ }
120
+ const settings = current['orionplace/settings.json'] ? JSON.parse(current['orionplace/settings.json']) : {};
121
+ const previous = current['orionplace/.generated.json'] ? JSON.parse(current['orionplace/.generated.json']) : {};
122
+ if (settings.backend) settings.backend = backendUrl(settings.backend);
123
+ if (settings.frontend) settings.frontend = frontendDomain(settings.frontend);
124
+ const oldFrontend = settings.frontend;
125
+ const frontends = settings.frontends ?? (settings.frontend ? [settings.frontend] : []);
126
+ if (!Array.isArray(frontends)) throw new Error('settings.frontends must be an array of domain names.');
127
+ settings.frontends = [...new Set(frontends.map(frontendDomain))];
128
+ if (settings.frontend && !settings.frontends.includes(settings.frontend)) settings.frontends.unshift(settings.frontend);
129
+ if (field === 'backend') settings.backend = setting;
130
+ if (field === 'mimic') {
131
+ if (!settings.frontends.includes(setting)) {
132
+ throw new Error(`Add this frontend before selecting it for MIMIC: ${setting}`);
133
+ }
134
+ settings.frontend = setting;
135
+ }
136
+ if (field === 'frontend') {
137
+ if (action === 'remove') {
138
+ if (!settings.frontends.includes(setting)) throw new Error(`Frontend is not managed by this project: ${setting}`);
139
+ settings.frontends = settings.frontends.filter(domain => domain !== setting);
140
+ if (settings.frontend === setting) settings.frontend = settings.frontends[0];
141
+ } else {
142
+ if (!settings.frontends.includes(setting)) settings.frontends.push(setting);
143
+ // Adding an alias preserves the local development domain. "set" selects it.
144
+ if (action === 'set' || !settings.frontend) settings.frontend = setting;
145
+ }
146
+ }
147
+ settings.frontend ||= settings.frontends[0];
148
+ if (!settings.frontend) delete settings.frontend;
149
+ const manifest = { ...previous };
150
+ const changes = new Map();
151
+ const preserved = [];
152
+ const skipped = [];
153
+ async function plan(name, content, { managed = false, preserve = false } = {}) {
154
+ const path = await safePath(root, name);
155
+ const existing = await read(path);
156
+ if (managed && existing !== null && existing !== content && previous[name] !== hash(existing)) {
157
+ if (preserve && previous[name]) { preserved.push(name); return; }
158
+ throw new Error(`Refusing to overwrite ${name}: it is not an unchanged generated file. No files were changed.`);
159
+ }
160
+ if (existing !== content) changes.set(name, content);
161
+ if (managed) manifest[name] = hash(content);
162
+ }
163
+ async function planRemoval(name) {
164
+ const existing = await read(await safePath(root, name));
165
+ if (existing !== null && previous[name] !== hash(existing)) {
166
+ throw new Error(`Refusing to remove ${name}: it is not an unchanged generated file. No files were changed.`);
167
+ }
168
+ if (existing !== null) changes.set(name, null);
169
+ delete manifest[name];
170
+ }
171
+ // All checks and content generation happen before the first write.
172
+ const isNewKit = !kit;
173
+ pkg.type ||= 'module';
174
+ pkg.scripts ||= {};
175
+ pkg.scripts.dev ||= 'vite dev';
176
+ pkg.scripts.build ||= 'vite build';
177
+ pkg.scripts.preview ||= 'vite preview';
178
+ pkg.scripts.prepare ||= 'svelte-kit sync || echo ""';
179
+ pkg.scripts.check ||= 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json';
180
+ pkg.scripts['orionplace:backend'] ||= 'orionplace-svelte set backend';
181
+ pkg.scripts['orionplace:frontend'] ||= 'orionplace-svelte set frontend';
182
+ pkg.scripts['orionplace:add-frontend'] ||= 'orionplace-svelte add frontend';
183
+ pkg.scripts['orionplace:remove-frontend'] ||= 'orionplace-svelte remove frontend';
184
+ pkg.scripts['orionplace:mimic'] ||= 'orionplace-svelte set mimic';
185
+ pkg.scripts['orionplace:help'] ||= 'orionplace-svelte help';
186
+ pkg.scripts['orionplace:update'] ||= 'orionplace-svelte update';
187
+ pkg.dependencies ||= {};
188
+ pkg.dependencies['orionplace-svelte'] ||= dependencies['orionplace-svelte'] || `^${metadata.version}`;
189
+ pkg.devDependencies ||= {};
190
+ pkg.devDependencies['@sveltejs/adapter-node'] ||= dependencies['@sveltejs/adapter-node'] || '^5.5.7';
191
+ // The generated Vite configuration reads the domain directory through Node APIs.
192
+ pkg.devDependencies['@types/node'] ||= dependencies['@types/node'] || '^22.18.0';
193
+ if (action === 'update') {
194
+ pkg.dependencies['orionplace-svelte'] = metadata.version;
195
+ if (pkg.devDependencies) delete pkg.devDependencies['orionplace-svelte'];
196
+ }
197
+ if (isNewKit) {
198
+ pkg.devDependencies['@sveltejs/kit'] = '^2.70.3';
199
+ pkg.devDependencies['@sveltejs/vite-plugin-svelte'] = '^7.3.0';
200
+ pkg.devDependencies.svelte ||= '^5.57.0';
201
+ pkg.devDependencies['svelte-check'] ||= '^4.7.6';
202
+ pkg.devDependencies.typescript ||= '^5.9.3';
203
+ pkg.devDependencies.vite ||= '^8.3.0';
204
+ }
205
+ const templateRoot = new URL('../templates/', import.meta.url);
206
+ async function planTemplates(directory = '') {
207
+ for (const entry of await readdir(new URL(directory, templateRoot), { withFileTypes: true })) {
208
+ const name = directory + entry.name;
209
+ if (entry.isDirectory()) { await planTemplates(name + '/'); continue; }
210
+ if (optional.includes(name) && !previous[name]) {
211
+ // Never replace a customer's own configuration, layout, page or hooks.
212
+ const existing = await Promise.all((equivalents[name] ?? [name])
213
+ .map(async variant => await read(await safePath(root, variant)) !== null ? variant : null));
214
+ const owned = existing.find(Boolean);
215
+ if (owned) { skipped.push(owned); continue; }
216
+ }
217
+ const source = (await readFile(new URL(name, templateRoot), 'utf8'))
218
+ .replaceAll('__ORIONPLACE_RUNTIME__', runtimeImport(name));
219
+ await plan(name, source, { managed: true, preserve: true });
220
+ }
221
+ }
222
+ await planTemplates();
223
+ if (!await read(await safePath(root, 'tsconfig.json'))) {
224
+ await plan('tsconfig.json', JSON.stringify(tsconfigTemplate, null, 2) + '\n', { managed: true });
225
+ }
226
+ await plan('package.json', JSON.stringify(pkg, null, 2) + '\n');
227
+ await plan('orionplace/runtime/marketplace.mjs', await readFile(new URL('../runtime/marketplace.mjs', import.meta.url), 'utf8'), { managed: true, preserve: true });
228
+ await plan('orionplace/runtime/marketplace.d.mts', await readFile(new URL('../runtime/marketplace.d.mts', import.meta.url), 'utf8'), { managed: true, preserve: true });
229
+ await plan('orionplace/README.md', projectReadme(settings), { managed: true, preserve: true });
230
+ // A new project starts explicitly empty; keep a pre-existing manual setting on init.
231
+ const hasMimicSetting = /^\s*(?:export\s+)?MIMIC_FRONT_DOMAIN\s*=/m.test(current['.env'] || '');
232
+ if (action !== 'update' && (settings.frontend || oldFrontend || !hasMimicSetting)) {
233
+ await plan('.env', updateEnvironment(current['.env'], settings.frontend || ''));
234
+ }
235
+ if (settings.backend && action !== 'update') {
236
+ const mappings = action === 'remove' || field === 'mimic' ? []
237
+ : field === 'frontend' ? [setting] : settings.frontends;
238
+ for (const domain of mappings) {
239
+ await plan(`sitemaps/${domain}.cfg`, settings.backend + '\n', { managed: true });
240
+ }
241
+ }
242
+ if (action === 'remove') await planRemoval(`sitemaps/${setting}.cfg`);
243
+ let ignore = current['.gitignore'] || '';
244
+ for (const entry of ['node_modules/', '.svelte-kit/', 'build/', '.env', '.env.*', '!.env.example']) {
245
+ if (!ignore.split(/\r?\n/).includes(entry)) ignore = ignore.replace(/\n*$/, '\n') + entry + '\n';
246
+ }
247
+ await plan('.gitignore', ignore);
248
+ if (action !== 'update') await plan('orionplace/settings.json', JSON.stringify(settings, null, 2) + '\n');
249
+ await plan('orionplace/.generated.json', JSON.stringify(manifest, null, 2) + '\n');
250
+ if (!dryRun) {
251
+ for (const [name, content] of changes) {
252
+ const target = await safePath(root, name);
253
+ if (content === null) { await unlink(target); continue; }
254
+ await mkdir(dirname(target), { recursive: true });
255
+ const temporary = `${target}.orionplace-${process.pid}.tmp`;
256
+ await writeFile(temporary, content, { mode: name === '.env' ? 0o600 : 0o644, flag: 'wx' });
257
+ await rename(temporary, target);
258
+ }
259
+ }
260
+ return { root, settings, changed: [...changes.keys()], removed: [...changes].filter(([, content]) => content === null).map(([name]) => name), preserved, skipped, dryRun, isNewKit };
261
+ }
package/src/update.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { resolve } from 'node:path';
3
+ import { setupProject } from './setup.mjs';
4
+
5
+ export function runNpm(args, cwd) {
6
+ const npmCli = process.env.npm_execpath;
7
+ const command = npmCli ? process.execPath : 'npm';
8
+ const commandArgs = npmCli ? [npmCli, ...args] : args;
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawn(command, commandArgs, { cwd, stdio: 'inherit', shell: false });
11
+ child.once('error', reject);
12
+ child.once('exit', (code, signal) => code === 0 ? resolve() : reject(new Error(
13
+ `npm ${args[0]} failed (${signal || code}).`,
14
+ )));
15
+ });
16
+ }
17
+
18
+ export async function updateProject({ cwd = process.cwd(), dryRun = false, local = false, npm = runNpm } = {}) {
19
+ cwd = resolve(cwd);
20
+ // Validate before downloading or writing; the latest CLI repeats preflight with its own templates.
21
+ const preview = await setupProject({ cwd, action: 'update', dryRun: true });
22
+ if (!local) {
23
+ console.log('Checking the latest Orionplace Svelte release…');
24
+ await npm(['exec', '--yes', '--prefer-online', '--package=orionplace-svelte@latest', '--',
25
+ 'orionplace-svelte', 'update', '--local', '--cwd', cwd, ...(dryRun ? ['--dry-run'] : [])], cwd);
26
+ return null; // The latest CLI reports its own changes.
27
+ }
28
+ if (dryRun) return preview;
29
+ const result = await setupProject({ cwd, action: 'update' });
30
+ try {
31
+ await npm(['install', '--no-audit', '--no-fund'], cwd);
32
+ } catch (error) {
33
+ throw new Error(`Source files were updated, but dependency installation failed. Run npm install in the project, then retry update. ${error.message}`);
34
+ }
35
+ return result;
36
+ }
@@ -0,0 +1,27 @@
1
+ import { isIP } from 'node:net';
2
+ const dns = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
3
+
4
+ export function frontendDomain(value) {
5
+ let url;
6
+ try { url = new URL(value.includes('://') ? value : `https://${value}`); }
7
+ catch { throw new Error('Use a public frontend hostname, for example shop.example.com.'); }
8
+ const domain = url.hostname.toLowerCase().replace(/\.$/, '');
9
+ if (!dns.test(domain) || isIP(domain) || url.username || url.password || url.port ||
10
+ url.search || url.hash || url.pathname !== '/' || !['http:', 'https:'].includes(url.protocol)) {
11
+ throw new Error('Frontend must be a public hostname without a port, path, or credentials.');
12
+ }
13
+ return domain;
14
+ }
15
+
16
+ export function backendUrl(value) {
17
+ let url;
18
+ try { url = new URL(value.includes('://') ? value : `https://${value}`); }
19
+ catch { throw new Error('Use the personal backend domain shown in your Orionplace account.'); }
20
+ const path = url.pathname.replace(/\/+$/, '');
21
+ if (url.protocol !== 'https:' || !dns.test(url.hostname) ||
22
+ !url.hostname.endsWith('.orionplace.app') || url.username || url.password ||
23
+ url.port || url.search || url.hash || !['', '/api'].includes(path)) {
24
+ throw new Error('Backend must be https://<your-project>.orionplace.app or its /api URL.');
25
+ }
26
+ return `${url.origin}/api`;
27
+ }