create-ortha-app 0.4.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +277 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/lib/conditionals.d.ts +37 -0
- package/dist/lib/conditionals.d.ts.map +1 -0
- package/dist/lib/conditionals.js +115 -0
- package/dist/lib/features.d.ts +185 -0
- package/dist/lib/features.d.ts.map +1 -0
- package/dist/lib/features.js +328 -0
- package/dist/lib/template.d.ts +46 -0
- package/dist/lib/template.d.ts.map +1 -0
- package/dist/lib/template.js +115 -0
- package/dist/lib/ui.d.ts +76 -0
- package/dist/lib/ui.d.ts.map +1 -0
- package/dist/lib/ui.js +310 -0
- package/dist/lib/validate.d.ts +13 -0
- package/dist/lib/validate.d.ts.map +1 -0
- package/dist/lib/validate.js +51 -0
- package/package.json +37 -0
- package/templates/default/README.md.tmpl +199 -0
- package/templates/default/_gitignore +8 -0
- package/templates/default/apps/admin/index.html +38 -0
- package/templates/default/apps/admin/src/main.tsx +5 -0
- package/templates/default/apps/admin/src/plugins.spec.ts +58 -0
- package/templates/default/apps/admin/src/plugins.ts +57 -0
- package/templates/default/apps/admin/src/styles.css +254 -0
- package/templates/default/apps/admin/tsconfig.json +29 -0
- package/templates/default/apps/admin/vite.config.mts +67 -0
- package/templates/default/apps/admin-e2e/playwright.config.ts +51 -0
- package/templates/default/apps/admin-e2e/src/auth.spec.ts +55 -0
- package/templates/default/apps/admin-e2e/src/support/seed.ts +62 -0
- package/templates/default/apps/admin-e2e/tsconfig.json +23 -0
- package/templates/default/apps/server/jest.config.js +38 -0
- package/templates/default/apps/server/jest.setup.js +20 -0
- package/templates/default/apps/server/ortha.config.ts +505 -0
- package/templates/default/apps/server/src/main.ts +26 -0
- package/templates/default/apps/server/src/plugins.spec.ts +71 -0
- package/templates/default/apps/server/src/plugins.ts +229 -0
- package/templates/default/apps/server/tsconfig.json +31 -0
- package/templates/default/apps/server-e2e/jest.config.js +47 -0
- package/templates/default/apps/server-e2e/src/api.spec.ts +107 -0
- package/templates/default/apps/server-e2e/src/global-setup.ts +41 -0
- package/templates/default/apps/server-e2e/src/jest.setup.ts +28 -0
- package/templates/default/apps/server-e2e/src/support/db.ts +143 -0
- package/templates/default/apps/server-e2e/src/support/test-app.ts +64 -0
- package/templates/default/apps/server-e2e/tsconfig.json +26 -0
- package/templates/default/docker-compose.yml +21 -0
- package/templates/default/env.tmpl +140 -0
- package/templates/default/package.json.tmpl +51 -0
- package/templates/default/tsconfig.json +18 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { defineConfig, loadEnv } from 'vite';
|
|
3
|
+
import react from '@vitejs/plugin-react';
|
|
4
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
5
|
+
|
|
6
|
+
export default defineConfig(({ mode }) => {
|
|
7
|
+
// This config lives inside the app it builds, so `root` is its own
|
|
8
|
+
// directory rather than wherever the command was typed — the CLI runs
|
|
9
|
+
// `vite --config apps/admin/vite.config.mts` from the project root.
|
|
10
|
+
const root = import.meta.dirname;
|
|
11
|
+
// The empty prefix is what makes unprefixed keys visible. Nothing here is
|
|
12
|
+
// inlined into the client bundle — only `import.meta.env` and `define` are.
|
|
13
|
+
// `.env` lives at the project root, two levels up from this config.
|
|
14
|
+
const env = {
|
|
15
|
+
...loadEnv(mode, resolve(root, '../..'), ''),
|
|
16
|
+
...process.env
|
|
17
|
+
};
|
|
18
|
+
const adminPort = Number(env['ADMIN_PORT']) || 4200;
|
|
19
|
+
const apiPort = Number(env['PORT']) || 3000;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
root,
|
|
23
|
+
server: {
|
|
24
|
+
port: adminPort,
|
|
25
|
+
// Fail on a taken port rather than drifting to the next free one:
|
|
26
|
+
// a silently-shifted port either 404s or lands on another app.
|
|
27
|
+
strictPort: true,
|
|
28
|
+
// Proxy the API under the admin's own origin, so the session
|
|
29
|
+
// cookie (httpOnly, SameSite=lax) is first-party in development —
|
|
30
|
+
// the same single origin `ortha start` gives you in production via
|
|
31
|
+
// `staticDir`, and the reason neither needs CORS.
|
|
32
|
+
//
|
|
33
|
+
// The key is a REGEX ending in a slash, not the bare `/api`
|
|
34
|
+
// prefix: a plain string key prefix-matches, so `/api` would also
|
|
35
|
+
// swallow sibling SPA routes whose path merely starts with those
|
|
36
|
+
// characters (`/api-tokens`) and forward them to the API, which
|
|
37
|
+
// 404s them on a hard refresh.
|
|
38
|
+
proxy: {
|
|
39
|
+
'^/api/': {
|
|
40
|
+
target: `http://localhost:${apiPort}`,
|
|
41
|
+
changeOrigin: true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
plugins: [tailwindcss(), react()],
|
|
46
|
+
build: {
|
|
47
|
+
// What `staticDir` serves in production — the shared `dist/` at
|
|
48
|
+
// the project root, not one inside this app.
|
|
49
|
+
outDir: resolve(root, '../../dist/admin'),
|
|
50
|
+
emptyOutDir: true
|
|
51
|
+
},
|
|
52
|
+
// Unit tests for the **admin** half. Declared here rather than in a
|
|
53
|
+
// `vitest.config.ts` of its own so the tests resolve modules exactly
|
|
54
|
+
// the way the app does — same plugins, same aliases, no second config
|
|
55
|
+
// to keep in step. The server half runs under Jest (`jest.config.js`),
|
|
56
|
+
// which is what emits the decorator metadata NestJS DI needs.
|
|
57
|
+
test: {
|
|
58
|
+
name: 'admin',
|
|
59
|
+
environment: 'jsdom',
|
|
60
|
+
globals: true,
|
|
61
|
+
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
|
62
|
+
coverage: {
|
|
63
|
+
reportsDirectory: resolve(root, '../../test-output/vitest')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The project root — two levels up from this app.
|
|
6
|
+
*
|
|
7
|
+
* `__dirname`, not `import.meta.dirname`: the app is `"type": "commonjs"` and
|
|
8
|
+
* Playwright loads a `.ts` config as CommonJS, where `import.meta` is a syntax
|
|
9
|
+
* error rather than an undefined value — so the failure is the config refusing
|
|
10
|
+
* to parse, with nothing in it about modules.
|
|
11
|
+
*/
|
|
12
|
+
const projectRoot = resolve(__dirname, '../..');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Admin end-to-end tests — the UI in a browser, against a **mocked** API.
|
|
16
|
+
*
|
|
17
|
+
* `webServer` runs the Vite dev server only. Specs intercept `/api` at the
|
|
18
|
+
* network layer (`src/support/seed.ts`), so a run needs no backend, no
|
|
19
|
+
* database and no migration: it is fast, hermetic, and when it fails it is the
|
|
20
|
+
* UI that is wrong. API behaviour is covered by `e2e/server`, where a failure
|
|
21
|
+
* names the endpoint instead of blaming a page.
|
|
22
|
+
*/
|
|
23
|
+
const adminPort = Number(process.env['ADMIN_PORT']) || 4200;
|
|
24
|
+
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
testDir: './src',
|
|
27
|
+
fullyParallel: true,
|
|
28
|
+
// A `.only` left in a file passes locally and silently stops running every
|
|
29
|
+
// other test in CI.
|
|
30
|
+
forbidOnly: !!process.env['CI'],
|
|
31
|
+
retries: process.env['CI'] ? 2 : 0,
|
|
32
|
+
reporter: process.env['CI'] ? 'github' : 'list',
|
|
33
|
+
use: {
|
|
34
|
+
baseURL: process.env['BASE_URL'] || `http://localhost:${adminPort}`,
|
|
35
|
+
// Retained on every failure rather than on a retry: outside CI there
|
|
36
|
+
// are no retries, so `on-first-retry` collects a trace exactly never —
|
|
37
|
+
// and a local run is the only post-mortem anyone gets.
|
|
38
|
+
trace: 'retain-on-failure'
|
|
39
|
+
},
|
|
40
|
+
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
|
41
|
+
webServer: {
|
|
42
|
+
command: `npx vite --config apps/admin/vite.config.mts --port ${adminPort} --strictPort`,
|
|
43
|
+
// Playwright resolves `cwd` against this config's directory, and the
|
|
44
|
+
// dev server has to start from the project root or it resolves neither
|
|
45
|
+
// `node_modules` nor the admin config path above.
|
|
46
|
+
cwd: projectRoot,
|
|
47
|
+
url: `http://localhost:${adminPort}`,
|
|
48
|
+
reuseExistingServer: !process.env['CI'],
|
|
49
|
+
timeout: 120_000
|
|
50
|
+
}
|
|
51
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
import { seedApi, SIGNED_IN_USER } from './support/seed';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The admin, driven in a browser against a mocked API.
|
|
6
|
+
*
|
|
7
|
+
* These assert what only a browser can see — that a route renders, that the
|
|
8
|
+
* gate holds, that the shell mounts. Anything about *what the API returns*
|
|
9
|
+
* belongs in `e2e/server`.
|
|
10
|
+
*/
|
|
11
|
+
test.describe('the admin shell', () => {
|
|
12
|
+
test('sends a signed-out visitor to sign in', async ({ page }) => {
|
|
13
|
+
await seedApi(page);
|
|
14
|
+
|
|
15
|
+
await page.goto('/');
|
|
16
|
+
|
|
17
|
+
await expect(
|
|
18
|
+
page.getByRole('textbox', { name: /email/i })
|
|
19
|
+
).toBeVisible();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('keeps a signed-out visitor off a private route', async ({ page }) => {
|
|
23
|
+
await seedApi(page);
|
|
24
|
+
|
|
25
|
+
// The gate is the shell's layout composing identity's `RequireAuth`.
|
|
26
|
+
// If a second plugin ever contributes a layout ahead of the shell's,
|
|
27
|
+
// this is the test that notices — the page renders, ungated.
|
|
28
|
+
await page.goto('/workspaces');
|
|
29
|
+
|
|
30
|
+
await expect(
|
|
31
|
+
page.getByRole('textbox', { name: /email/i })
|
|
32
|
+
).toBeVisible();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('renders the app shell for a signed-in user', async ({ page }) => {
|
|
36
|
+
await seedApi(page, { me: SIGNED_IN_USER, workspaces: [] });
|
|
37
|
+
|
|
38
|
+
await page.goto('/workspaces');
|
|
39
|
+
|
|
40
|
+
// The `<main>` landmark comes from the shell's layout — its presence is
|
|
41
|
+
// what says the chrome mounted rather than a bare route.
|
|
42
|
+
await expect(page.getByRole('main')).toBeVisible();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('serves a deep route on a hard refresh', async ({ page }) => {
|
|
46
|
+
await seedApi(page, { me: SIGNED_IN_USER, workspaces: [] });
|
|
47
|
+
|
|
48
|
+
// A client-side route reached by URL rather than by clicking. In
|
|
49
|
+
// production this is the SPA fallback's job; in dev it is Vite's.
|
|
50
|
+
// Either way a 404 here means the app is unreachable on refresh.
|
|
51
|
+
const response = await page.goto('/workspaces/settings');
|
|
52
|
+
|
|
53
|
+
expect(response?.status()).toBeLessThan(400);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Page, Route } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/** The API responses a run should answer with. */
|
|
4
|
+
export interface Seed {
|
|
5
|
+
/** The signed-in user, or `null` for a signed-out session. */
|
|
6
|
+
me?: unknown;
|
|
7
|
+
/** Workspaces the sidebar lists. */
|
|
8
|
+
workspaces?: unknown[];
|
|
9
|
+
/** Anything else, keyed by path suffix — `'content/article'`. */
|
|
10
|
+
routes?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mocks `/api` at the network layer.
|
|
15
|
+
*
|
|
16
|
+
* The admin e2e suite drives the **UI**, not the stack: it runs against the
|
|
17
|
+
* Vite dev server with no backend and no database, so a run is fast, hermetic,
|
|
18
|
+
* and says something specific when it fails. Testing the API through a browser
|
|
19
|
+
* would be slower and would blame the UI for server bugs — those belong in
|
|
20
|
+
* `e2e/server`, where a failure names the endpoint.
|
|
21
|
+
*
|
|
22
|
+
* Anything not seeded gets a 404, deliberately: a silent empty `200` makes a
|
|
23
|
+
* page render its empty state and pass, which is exactly the false green this
|
|
24
|
+
* exists to avoid.
|
|
25
|
+
*/
|
|
26
|
+
export async function seedApi(page: Page, seed: Seed = {}): Promise<void> {
|
|
27
|
+
const json = (route: Route, body: unknown, status = 200) =>
|
|
28
|
+
route.fulfill({
|
|
29
|
+
status,
|
|
30
|
+
contentType: 'application/json',
|
|
31
|
+
body: JSON.stringify(body)
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await page.route('**/api/**', async (route) => {
|
|
35
|
+
const path = new URL(route.request().url()).pathname.replace(
|
|
36
|
+
/^\/api\/?/,
|
|
37
|
+
''
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (path === 'auth/me') {
|
|
41
|
+
return seed.me
|
|
42
|
+
? json(route, seed.me)
|
|
43
|
+
: json(route, { message: 'Unauthorized' }, 401);
|
|
44
|
+
}
|
|
45
|
+
if (path === 'workspaces') {
|
|
46
|
+
return json(route, { items: seed.workspaces ?? [], total: 0 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const custom = seed.routes?.[path];
|
|
50
|
+
if (custom !== undefined) return json(route, custom);
|
|
51
|
+
|
|
52
|
+
return json(route, { message: `Unmocked: ${path}` }, 404);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A signed-in session, for tests that start past the login screen. */
|
|
57
|
+
export const SIGNED_IN_USER = {
|
|
58
|
+
id: '00000000-0000-0000-0000-000000000001',
|
|
59
|
+
email: 'e2e@example.com',
|
|
60
|
+
name: 'E2E User',
|
|
61
|
+
permissions: []
|
|
62
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"es2023",
|
|
6
|
+
"dom"
|
|
7
|
+
],
|
|
8
|
+
"module": "esnext",
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"types": [
|
|
11
|
+
"node"
|
|
12
|
+
],
|
|
13
|
+
"strict": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"noEmit": true
|
|
18
|
+
},
|
|
19
|
+
"include": [
|
|
20
|
+
"src/**/*.ts",
|
|
21
|
+
"playwright.config.ts"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the **server** half.
|
|
3
|
+
*
|
|
4
|
+
* Jest with `@swc/jest`, not Vitest, and that is not a style choice: the server
|
|
5
|
+
* is a NestJS app, its DI reads `emitDecoratorMetadata`, and Vitest's esbuild
|
|
6
|
+
* transform does not emit it — providers resolve as `undefined` with no error
|
|
7
|
+
* that names the cause. swc emits it, in the same legacy-decorator mode
|
|
8
|
+
* `tsconfig.server.json` compiles with.
|
|
9
|
+
*
|
|
10
|
+
* The admin half runs under Vitest instead (see `apps/admin/vite.config.mts`), because it
|
|
11
|
+
* is a Vite app and sharing its config is the only way the two agree on how
|
|
12
|
+
* modules resolve. Two runners, one per half, each matching its own toolchain.
|
|
13
|
+
*/
|
|
14
|
+
module.exports = {
|
|
15
|
+
displayName: 'server',
|
|
16
|
+
testEnvironment: 'node',
|
|
17
|
+
rootDir: '../..',
|
|
18
|
+
testMatch: ['<rootDir>/apps/server/**/*.spec.ts'],
|
|
19
|
+
setupFiles: ['<rootDir>/apps/server/jest.setup.js'],
|
|
20
|
+
transform: {
|
|
21
|
+
'^.+\\.[tj]s$': [
|
|
22
|
+
'@swc/jest',
|
|
23
|
+
{
|
|
24
|
+
jsc: {
|
|
25
|
+
target: 'es2022',
|
|
26
|
+
parser: { syntax: 'typescript', decorators: true },
|
|
27
|
+
transform: {
|
|
28
|
+
legacyDecorator: true,
|
|
29
|
+
decoratorMetadata: true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
module: { type: 'commonjs' }
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
moduleFileExtensions: ['ts', 'js', 'json'],
|
|
37
|
+
coverageDirectory: 'test-output/jest'
|
|
38
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment for the server unit tests.
|
|
3
|
+
*
|
|
4
|
+
* They build the plugin list, which imports `ortha.config.ts` — and that file
|
|
5
|
+
* deliberately refuses to load without `DATABASE_URL`, because a blank
|
|
6
|
+
* connection string reaches `pg` as "use the libpq defaults" and migrations
|
|
7
|
+
* then run against whatever database the local environment happens to name.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here connects to anything: the plugin list is a pure function of the
|
|
10
|
+
* config. So load a real `.env` when there is one, and fall back to
|
|
11
|
+
* placeholders — which is what lets `npm test` run in CI with no `.env` at all.
|
|
12
|
+
*/
|
|
13
|
+
const { existsSync } = require('node:fs');
|
|
14
|
+
|
|
15
|
+
if (existsSync('.env') && typeof process.loadEnvFile === 'function') {
|
|
16
|
+
process.loadEnvFile('.env');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
process.env.DATABASE_URL ||=
|
|
20
|
+
'postgresql://ortha:ortha@localhost:5432/placeholder';
|