@aurostack/stacks 0.2.0 → 0.2.2

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 (43) hide show
  1. package/cli/stack.mjs +1 -1
  2. package/package.json +1 -1
  3. package/templates/nest-api/files/.bin/secrets.ts +15 -6
  4. package/templates/nest-api/files/.env.example +11 -4
  5. package/templates/nest-api/files/_gitignore +1 -0
  6. package/templates/nest-api/files/src/app.module.ts +7 -5
  7. package/templates/nest-api/files/src/app.setup.ts +5 -3
  8. package/templates/nest-api/files/src/common/__tests__/origin-builder.spec.ts +24 -0
  9. package/templates/nest-api/files/src/common/constants/pino.ts +14 -0
  10. package/templates/nest-api/files/src/common/misc/origin-builder.ts +6 -3
  11. package/templates/nest-api/files/src/common/modules/logger.module.ts +3 -2
  12. package/templates/nest-api/files/src/common/services/config.service.ts +1 -1
  13. package/templates/nest-api/files/src/lib/auth.ts +2 -2
  14. package/templates/nest-api/files/src/notifications/services/notifications.service.ts +4 -2
  15. package/templates/nest-api/template.json +18 -4
  16. package/templates/node-worker/files/.bin/secrets.ts +15 -6
  17. package/templates/node-worker/files/_gitignore +1 -0
  18. package/templates/node-worker/files/package.json +1 -0
  19. package/templates/node-worker/files/src/index.ts +39 -16
  20. package/templates/node-worker/files/src/logger.ts +14 -1
  21. package/templates/node-worker/files/src/utils/worker.ts +1 -1
  22. package/templates/py-worker/files/utils/db.py +20 -27
  23. package/templates/py-worker/files/utils/logger.py +9 -2
  24. package/templates/py-worker/files/utils/worker.py +2 -2
  25. package/templates/react-app/files/.bin/secrets.js +8 -2
  26. package/templates/react-app/files/.forgejo/workflows/build-and-push.yml +3 -0
  27. package/templates/react-app/files/Dockerfile +10 -0
  28. package/templates/react-app/files/_gitignore +1 -0
  29. package/templates/react-app/files/build.sh +3 -1
  30. package/templates/react-app/template.json +2 -2
  31. package/templates/react-monorepo/files/.forgejo/workflows/build-and-push.yml +15 -11
  32. package/templates/react-monorepo/files/Dockerfile +21 -7
  33. package/templates/react-monorepo/files/_gitignore +1 -0
  34. package/templates/react-monorepo/files/apps/admin/.bin/secrets.js +8 -3
  35. package/templates/react-monorepo/files/apps/admin/src/app/router.tsx +11 -7
  36. package/templates/react-monorepo/files/apps/auth/.bin/secrets.js +8 -3
  37. package/templates/react-monorepo/files/apps/client/.bin/secrets.js +8 -3
  38. package/templates/react-monorepo/files/apps/client/src/vite-env.d.ts +1 -1
  39. package/templates/react-monorepo/files/apps/landing/.bin/secrets.js +8 -3
  40. package/templates/react-monorepo/files/build.sh +3 -1
  41. package/templates/react-monorepo/files/packages/types/.bin/secrets.js +8 -3
  42. package/templates/react-monorepo/files/turbo.json +1 -1
  43. package/templates/react-monorepo/template.json +41 -9
package/cli/stack.mjs CHANGED
@@ -94,7 +94,7 @@ ${c.bold('extract options')}
94
94
 
95
95
  ${c.bold('Examples')}
96
96
  stack new nest-api ~/Projects/acme/api --with realtime,media
97
- stack new react-monorepo ~/Projects/acme/web --without landing
97
+ stack new react-monorepo ~/Projects/acme/web --with app-landing,app-admin
98
98
  stack extract nest-api --source ~/Projects/acme/web/backend
99
99
  `;
100
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurostack/stacks",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Scaffold new projects from house templates: a NestJS API, React app or Turborepo monorepo, and Node or Python workers. Subtractive templates plus a zero-dependency generator.",
5
5
  "license": "MIT",
6
6
  "author": "Aurostack (https://github.com/aurostack-org)",
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import shell from 'shelljs';
2
3
  import yargs from 'yargs';
3
4
  import { hideBin } from 'yargs/helpers';
@@ -52,9 +53,7 @@ const getCmd = (env: string) => {
52
53
  if (!PROJECT_ID) {
53
54
  throw new Error('INFISICAL_PROJECT_ID is not set — nothing to fetch from.');
54
55
  }
55
- return `infisical export --projectId="${PROJECT_ID}" --path=${SECRET_PATH} --env=${env} > ${getEnvFile(
56
- env
57
- )}`;
56
+ return `infisical export --projectId="${PROJECT_ID}" --path=${SECRET_PATH} --env=${env}`;
58
57
  };
59
58
 
60
59
  const log = (env: string) => console.log(`Fetching secrets for '${env}'...`);
@@ -62,7 +61,17 @@ const log = (env: string) => console.log(`Fetching secrets for '${env}'...`);
62
61
  (async () => {
63
62
  const env = await getArg();
64
63
  const command = getCmd(env);
64
+ const file = getEnvFile(env);
65
65
  log(env);
66
- shell.exec(command);
67
- process.exit(0);
68
- })().catch(console.error);
66
+ // Export first and write only on success: a shell `>` redirect would empty
67
+ // the existing file before infisical had the chance to fail.
68
+ const result = shell.exec(command, { silent: true });
69
+ if (result.code !== 0) {
70
+ throw new Error(result.stderr.trim() || `infisical exited with ${result.code}`);
71
+ }
72
+ fs.writeFileSync(file, result.stdout);
73
+ console.log(`Wrote ${file}`);
74
+ })().catch((err: unknown) => {
75
+ console.error(err instanceof Error ? err.message : err);
76
+ process.exit(1);
77
+ });
@@ -8,7 +8,10 @@ APP_HOST='0.0.0.0'
8
8
  PORT='5000'
9
9
  SERVER_HOST='http://localhost:5000'
10
10
  FRONTEND_HOST='http://localhost:3000'
11
- # Extra CORS origins beyond FRONTEND_HOST, comma separated.
11
+ # Extra origins beyond FRONTEND_HOST, comma separated: trusted by auth in every
12
+ # environment, allowed by CORS in development only. A full origin
13
+ # (`http://localhost:3001`) is used as written; a bare hostname (`example.com`)
14
+ # expands to https://example.com and its auth., app. and admin. subdomains.
12
15
  MISC_CORS_ORIGINS=''
13
16
 
14
17
  DATABASE_URL='postgresql://root:postgres@localhost:5432/main'
@@ -16,9 +19,13 @@ DATABASE_URL='postgresql://root:postgres@localhost:5432/main'
16
19
  # openssl rand -base64 32
17
20
  BETTER_AUTH_SECRET='change-me-openssl-rand-base64-32'
18
21
  BETTER_AUTH_URL='http://localhost:5000'
19
- # Leading dot shares the session cookie across subdomains. `.lvh.me` resolves
20
- # to 127.0.0.1 for every subdomain, which makes local multi-app auth work.
21
- BETTER_AUTH_COOKIE_DOMAIN='.lvh.me'
22
+ # Empty: the session cookie belongs to the API's own host, which is what
23
+ # localhost development and a single-origin frontend need. Set a parent domain
24
+ # with a leading dot (`.example.com`) when frontends live on subdomains, to share
25
+ # one login across them; locally, `.lvh.me` does the same with `*.lvh.me` hosts
26
+ # (every subdomain resolves to 127.0.0.1). The domain must cover the host the
27
+ # browser talks to, or the cookie is dropped and every sign-in bounces.
28
+ BETTER_AUTH_COOKIE_DOMAIN=''
22
29
  BETTER_AUTH_COOKIE_PREFIX='acme'
23
30
 
24
31
  # Seeded by `yarn db:seed`.
@@ -47,6 +47,7 @@ yarn-error.log
47
47
  .env
48
48
  .env.test
49
49
  .env.production
50
+ .env.staging
50
51
  .npmrc
51
52
  docker-compose.dev.yaml
52
53
 
@@ -81,13 +81,15 @@ import { OriginBuilder } from 'common/misc';
81
81
  ipAddressHeaders: config.rateLimit.ipHeaders
82
82
  },
83
83
  // @feature:end
84
- // Share the session cookie across subdomains so one login covers
85
- // every frontend. Domain comes from BETTER_AUTH_COOKIE_DOMAIN
86
- // (`.lvh.me` in dev, your apex in production).
84
+ // With BETTER_AUTH_COOKIE_DOMAIN set (`.example.com`), the session
85
+ // cookie is shared across subdomains so one login covers every
86
+ // frontend. Empty, it belongs to the API's own host: right for
87
+ // localhost and for a single-origin frontend. A browser drops a
88
+ // cookie whose domain doesn't cover the host that set it.
87
89
  cookiePrefix: config.betterAuth.cookiePrefix,
88
90
  crossSubDomainCookies: {
89
- enabled: true,
90
- domain: config.betterAuth.cookieDomain
91
+ enabled: Boolean(config.betterAuth.cookieDomain),
92
+ domain: config.betterAuth.cookieDomain || undefined
91
93
  },
92
94
  database: {
93
95
  joins: true
@@ -120,9 +120,10 @@ export const enableHelmet = (app: INestApplication) => {
120
120
  // @feature:start openapi, queue
121
121
  /**
122
122
  * Lock the operator-facing surfaces behind basic auth. Every path added here is
123
- * something that leaks internals if left open — the API reference and the queue
124
- * dashboard — so a new one belongs in this list on
125
- * the same commit that mounts it.
123
+ * something that leaks internals if left open — the API reference (Scalar at
124
+ * /docs, Swagger UI at /openapi, the raw document at /openapi-json) and the
125
+ * queue dashboard — so a new one belongs in this list on the same commit that
126
+ * mounts it.
126
127
  */
127
128
  export const enableBasicAuth = (app: INestApplication) => {
128
129
  const config = app.get(CustomConfigService);
@@ -131,6 +132,7 @@ export const enableBasicAuth = (app: INestApplication) => {
131
132
  app.use(
132
133
  [
133
134
  '/docs', // @feature openapi
135
+ '/openapi', // @feature openapi
134
136
  '/openapi-json', // @feature openapi
135
137
  '/dashboard' // @feature queue
136
138
  ],
@@ -0,0 +1,24 @@
1
+ import { OriginBuilder } from 'common/misc/origin-builder';
2
+
3
+ describe('OriginBuilder', () => {
4
+ it('expands a bare hostname into https origins for it and its subdomains', () => {
5
+ expect(OriginBuilder.build('example.com')).toEqual([
6
+ 'https://example.com',
7
+ 'https://auth.example.com',
8
+ 'https://app.example.com',
9
+ 'https://admin.example.com'
10
+ ]);
11
+ });
12
+
13
+ it('takes an entry with a scheme as one origin, as written', () => {
14
+ expect(OriginBuilder.build('http://localhost:3001, http://localhost:3002/')).toEqual([
15
+ 'http://localhost:3001',
16
+ 'http://localhost:3002'
17
+ ]);
18
+ });
19
+
20
+ it('is empty for an empty list', () => {
21
+ expect(OriginBuilder.build('')).toEqual([]);
22
+ expect(OriginBuilder.build(undefined)).toEqual([]);
23
+ });
24
+ });
@@ -1,3 +1,17 @@
1
+ /**
2
+ * pino-pretty is a dev dependency, so the production image doesn't have it.
3
+ * Pretty-print only where it's installed; anywhere else (an image run with an
4
+ * APP_ENV other than production, say) log JSON to stdout instead of crashing.
5
+ */
6
+ export const PRETTY_AVAILABLE = (() => {
7
+ try {
8
+ require.resolve('pino-pretty');
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ })();
14
+
1
15
  export const PINO_CONSOLE_TARGET = {
2
16
  target: 'pino-pretty',
3
17
  options: {
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Builds a list of origins for CORS configuration, given a list of hostnames.
3
- * Each hostname will generate the main domain and subdomains
4
- * for app, auth, and admin.
2
+ * Builds a list of origins for CORS configuration from a list of entries.
3
+ * A bare hostname (`shop.example.com`) generates https origins for it and its
4
+ * app, auth and admin subdomains. An entry with a scheme
5
+ * (`http://localhost:3001`) is taken as one origin, as written: that is how
6
+ * frontends on other local ports are allowed.
5
7
  *
6
8
  * @remarks This is mainly for development purposes
7
9
  * For frontend developers on the team who cannot run a standalone
@@ -28,6 +30,7 @@ export class OriginBuilder {
28
30
  * Origins for a single hostname: the main domain plus each subdomain.
29
31
  */
30
32
  fromHost(hostname: string): string[] {
33
+ if (hostname.includes('://')) return [hostname.replace(/\/+$/, '')];
31
34
  return [
32
35
  `${this.protocol}${hostname}`,
33
36
  ...this.subdomains.map((sub) => `${this.protocol}${sub}.${hostname}`)
@@ -7,7 +7,8 @@ import { LoggerService } from '../services';
7
7
  import {
8
8
  PINO_CONSOLE_TARGET,
9
9
  PINO_FILE_TARGET,
10
- PINO_CONSOLE_TARGET_PROD
10
+ PINO_CONSOLE_TARGET_PROD,
11
+ PRETTY_AVAILABLE
11
12
  } from '../constants';
12
13
 
13
14
  @Global()
@@ -23,7 +24,7 @@ import {
23
24
  pinoHttp: {
24
25
  level: config.logger.level,
25
26
  transport:
26
- config.app.env === 'production'
27
+ config.app.env === 'production' || !PRETTY_AVAILABLE
27
28
  ? { targets: [PINO_CONSOLE_TARGET_PROD, PINO_FILE_TARGET] }
28
29
  : { targets: [PINO_CONSOLE_TARGET, PINO_FILE_TARGET] },
29
30
  autoLogging: {
@@ -172,7 +172,7 @@ namespace Config {
172
172
  DATABASE_URL: str(),
173
173
  BETTER_AUTH_SECRET: str(),
174
174
  BETTER_AUTH_URL: str(),
175
- BETTER_AUTH_COOKIE_DOMAIN: str(),
175
+ BETTER_AUTH_COOKIE_DOMAIN: z.string().default(''),
176
176
  BETTER_AUTH_COOKIE_PREFIX: str(),
177
177
  SUPERUSER_EMAIL: str(),
178
178
  SUPERUSER_PASSWORD: str(),
@@ -33,8 +33,8 @@ export const auth = betterAuth({
33
33
  // sessions through this one, so a different cookie name would reject them.
34
34
  cookiePrefix: env.BETTER_AUTH_COOKIE_PREFIX,
35
35
  crossSubDomainCookies: {
36
- enabled: true,
37
- domain: env.BETTER_AUTH_COOKIE_DOMAIN
36
+ enabled: Boolean(env.BETTER_AUTH_COOKIE_DOMAIN),
37
+ domain: env.BETTER_AUTH_COOKIE_DOMAIN || undefined
38
38
  },
39
39
  database: {
40
40
  joins: true
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
2
2
  import { NotificationType, Prisma } from '@db/client';
3
3
  import { PrismaService, LoggerService } from 'common/services';
4
4
  import { ok } from 'common/entity';
5
- import { RealtimeService } from 'realtime/services/realtime.service';
5
+ import { RealtimeService } from 'realtime/services/realtime.service'; // @feature realtime
6
6
  import {
7
7
  NotificationEntity,
8
8
  PaginatedNotificationEntity,
@@ -14,7 +14,7 @@ import { NotificationFilters } from '../dto';
14
14
  export class NotificationsService {
15
15
  constructor(
16
16
  private readonly db: PrismaService,
17
- private readonly realtime: RealtimeService,
17
+ private readonly realtime: RealtimeService, // @feature realtime
18
18
  private readonly logger: LoggerService
19
19
  ) {}
20
20
 
@@ -36,9 +36,11 @@ export class NotificationsService {
36
36
  const notification = await this.db.notification.create({
37
37
  data: { userId, type, data }
38
38
  });
39
+ // @feature:start realtime
39
40
  // Sockets bypass the HTTP serializer; encode through the same schema so
40
41
  // the event has exactly the shape the REST endpoints return.
41
42
  this.realtime.notifyUser(userId, NotificationEntity.parse(notification));
43
+ // @feature:end
42
44
  return notification;
43
45
  } catch (err) {
44
46
  this.logger.warn(
@@ -47,6 +47,12 @@
47
47
  "regex": true,
48
48
  "flags": "gm"
49
49
  },
50
+ {
51
+ "from": "^(SERVER_HOST|BETTER_AUTH_URL)='http://localhost:5000'$",
52
+ "to": "$1='http://localhost:{{port}}'",
53
+ "regex": true,
54
+ "flags": "gm"
55
+ },
50
56
  {
51
57
  "from": "^EXPOSE 5000$",
52
58
  "to": "EXPOSE {{port}}",
@@ -171,6 +177,9 @@
171
177
  "observability": {
172
178
  "title": "Observability: OpenTelemetry and health checks",
173
179
  "description": "OpenTelemetry traces, logs and metrics pushed over OTLP (built for OpenObserve; set OPENOBSERVE_URL/ORG/TOKEN, or any OTEL_* backend), plus Terminus health checks with a Prisma indicator. Off at runtime until connected.",
180
+ "requires": [
181
+ "http-client"
182
+ ],
174
183
  "default": true,
175
184
  "files": [
176
185
  "src/common/controllers/health.controller.ts",
@@ -217,10 +226,11 @@
217
226
  "media": {
218
227
  "title": "File uploads (S3)",
219
228
  "description": "MediaService and S3Service for public and private uploads, with multipart decorators.",
220
- "default": true,
221
229
  "requires": [
222
- "auth"
230
+ "auth",
231
+ "queue"
223
232
  ],
233
+ "default": true,
224
234
  "files": [
225
235
  "src/media/**",
226
236
  "src/users/dto/avatar.dto.ts"
@@ -276,7 +286,7 @@
276
286
  },
277
287
  "notifications": {
278
288
  "title": "Persistent user notifications",
279
- "description": "A Notification model plus dispatch/read endpoints. Emits over the realtime layer when that feature is also on, and persists either way.",
289
+ "description": "A Notification model plus dispatch/read endpoints. Pushes each new notification over the realtime layer when that feature is also on, and persists either way.",
280
290
  "requires": [
281
291
  "auth"
282
292
  ],
@@ -291,7 +301,8 @@
291
301
  "description": "A session-aware FeatureFlagGuard and FeatureFlagService for gating routes on flags.",
292
302
  "requires": [
293
303
  "cache",
294
- "auth"
304
+ "auth",
305
+ "scheduler"
295
306
  ],
296
307
  "files": [
297
308
  "src/common/services/feature-flag.service.ts",
@@ -408,6 +419,9 @@
408
419
  "ci": {
409
420
  "title": "Forgejo Actions CI",
410
421
  "description": "A workflow that spins up the test stack and runs both Vitest projects on pull requests.",
422
+ "requires": [
423
+ "secrets"
424
+ ],
411
425
  "default": true,
412
426
  "files": [
413
427
  ".forgejo/**"
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import shell from 'shelljs';
2
3
  import yargs from 'yargs';
3
4
  import { hideBin } from 'yargs/helpers';
@@ -52,9 +53,7 @@ const getCmd = (env: string) => {
52
53
  if (!PROJECT_ID) {
53
54
  throw new Error('INFISICAL_PROJECT_ID is not set — nothing to fetch from.');
54
55
  }
55
- return `infisical export --projectId="${PROJECT_ID}" --path=${SECRET_PATH} --env=${env} > ${getEnvFile(
56
- env
57
- )}`;
56
+ return `infisical export --projectId="${PROJECT_ID}" --path=${SECRET_PATH} --env=${env}`;
58
57
  };
59
58
 
60
59
  const log = (env: string) => console.log(`Fetching secrets for '${env}'...`);
@@ -62,7 +61,17 @@ const log = (env: string) => console.log(`Fetching secrets for '${env}'...`);
62
61
  (async () => {
63
62
  const env = await getArg();
64
63
  const command = getCmd(env);
64
+ const file = getEnvFile(env);
65
65
  log(env);
66
- shell.exec(command);
67
- process.exit(0);
68
- })().catch(console.error);
66
+ // Export first and write only on success: a shell `>` redirect would empty
67
+ // the existing file before infisical had the chance to fail.
68
+ const result = shell.exec(command, { silent: true });
69
+ if (result.code !== 0) {
70
+ throw new Error(result.stderr.trim() || `infisical exited with ${result.code}`);
71
+ }
72
+ fs.writeFileSync(file, result.stdout);
73
+ console.log(`Wrote ${file}`);
74
+ })().catch((err: unknown) => {
75
+ console.error(err instanceof Error ? err.message : err);
76
+ process.exit(1);
77
+ });
@@ -37,6 +37,7 @@ lerna-debug.log*
37
37
  .env
38
38
  .env.test
39
39
  .env.production
40
+ .env.staging
40
41
  .npmrc
41
42
  docker-compose.dev.yaml
42
43
 
@@ -72,6 +72,7 @@
72
72
  "@types/node": "^26.1.0",
73
73
  "@types/shelljs": "^0.10.0",
74
74
  "@types/yargs": "^17.0.35",
75
+ "dotenv-cli": "^7.2.1",
75
76
  "eslint": "^10.6.0",
76
77
  "eslint-config-prettier": "^10.1.8",
77
78
  "globals": "^17.7.0",
@@ -2,19 +2,42 @@ import logger from '#app/logger.js';
2
2
  import * as Workers from '#app/workers/index.js'; // @feature queue
3
3
  import { createTemporalWorker } from '#app/temporal/worker.js'; // @feature temporal
4
4
 
5
- (() => {
6
- logger.info('Worker started');
7
- void Workers.example.run(); // @feature queue
8
- // @feature:start temporal
9
- // Temporal handles SIGINT/SIGTERM itself: it stops polling and lets the
10
- // current tasks finish. Exit once it has, or open queue connections would
11
- // keep the process alive.
12
- createTemporalWorker()
13
- .then((worker) => worker.run())
14
- .then(() => process.exit(0))
15
- .catch((err: unknown) => {
16
- logger.fatal({ err }, 'Temporal worker failed');
17
- process.exit(1);
18
- });
19
- // @feature:end
20
- })();
5
+ // Everything still running when a shutdown starts: each entry settles once its
6
+ // worker has stopped taking new work and finished what it had.
7
+ const running: Promise<unknown>[] = [];
8
+
9
+ logger.info('Worker started');
10
+
11
+ // @feature:start queue
12
+ // Add each new BullMQ worker to this list: it is started here and closed on
13
+ // shutdown (close() stops fetching and waits for the active job).
14
+ const queueWorkers = [Workers.example];
15
+ for (const worker of queueWorkers) void worker.run();
16
+ // @feature:end
17
+
18
+ // @feature:start temporal
19
+ // Temporal handles SIGINT/SIGTERM itself: it stops polling and lets running
20
+ // tasks finish, and then run() resolves.
21
+ const temporal = createTemporalWorker().then((worker) => worker.run());
22
+ temporal.catch((err: unknown) => {
23
+ logger.fatal({ err }, 'Temporal worker failed');
24
+ process.exit(1);
25
+ });
26
+ running.push(temporal);
27
+ // @feature:end
28
+
29
+ let stopping = false;
30
+ const shutdown = (signal: NodeJS.Signals) => {
31
+ // `on` rather than `once`, and idempotent: the telemetry preload re-raises
32
+ // the signal after flushing, and that second delivery must not kill the
33
+ // process while jobs are still finishing.
34
+ if (stopping) return;
35
+ stopping = true;
36
+ logger.info({ signal }, 'Shutting down');
37
+ void Promise.allSettled([
38
+ ...queueWorkers.map((worker) => worker.close()), // @feature queue
39
+ ...running
40
+ ]).then(() => process.exit(0));
41
+ };
42
+ process.on('SIGTERM', shutdown);
43
+ process.on('SIGINT', shutdown);
@@ -1,3 +1,4 @@
1
+ import { createRequire } from 'node:module';
1
2
  import pino, { stdTimeFunctions, levels } from 'pino';
2
3
  import config from '#app/config.js';
3
4
 
@@ -26,10 +27,22 @@ const PINO_CONSOLE_TARGET_PROD = {
26
27
  }
27
28
  };
28
29
 
30
+ // pino-pretty is a dev dependency, so the production image doesn't have it.
31
+ // Pretty-print only where it's installed; anywhere else (an image run with an
32
+ // APP_ENV other than production, say) log JSON to stdout instead of crashing.
33
+ const prettyAvailable = (() => {
34
+ try {
35
+ createRequire(import.meta.url).resolve('pino-pretty');
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ })();
41
+
29
42
  const logger = pino({
30
43
  level: config.app.logLevel,
31
44
  transport:
32
- config.app.env === 'production'
45
+ config.app.env === 'production' || !prettyAvailable
33
46
  ? { targets: [PINO_CONSOLE_TARGET_PROD, PINO_FILE_TARGET] }
34
47
  : { targets: [PINO_CONSOLE_TARGET, PINO_FILE_TARGET] },
35
48
  base: {
@@ -14,7 +14,7 @@ export const createWorker = <
14
14
  const connection = {
15
15
  host: config.redis.host,
16
16
  port: config.redis.port,
17
- user: config.redis.user,
17
+ username: config.redis.user,
18
18
  password: config.redis.password
19
19
  };
20
20
 
@@ -1,37 +1,22 @@
1
1
  """
2
- @File: db.py
3
- @Version: 1.0
2
+ Database access over SQLAlchemy, with the connection settings from config
3
+ (DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME).
4
4
 
5
- Database wrapper to manage PostgreSQL connections using SQLAlchemy
5
+ Usage:
6
6
 
7
- DB('my_database') will create a connection to the specified PostgreSQL database
8
- using credentials from the .env file (user, password, host, port).
7
+ from utils.db import DB
9
8
 
10
- Usage example:
11
-
12
- from utils import DB
13
-
14
- db = DB('my_database')
15
-
16
- # Read data
17
- df = db.read_sql('SELECT * FROM my_table')
18
-
19
- # Write data
20
- db.write_sql(df, 'my_table', if_exists='append')
21
-
22
- # Use engine or session without having to create a new connection or variable
23
- db.session.query(...)
24
- db.engine.execute(...)
25
- # ... perform operations ...
9
+ db = DB()
10
+ rows = db.session.execute(select(MyModel)).scalars().all()
26
11
  db.session.commit()
27
-
28
- # Close the connection when done
29
12
  db.close()
30
13
 
14
+ The models under models/ are hand-written to match the API's Prisma schema:
15
+ the API owns the tables and their migrations.
31
16
  """
32
17
 
33
18
  from sqlalchemy import create_engine
34
- from sqlalchemy.engine import Connection
19
+ from sqlalchemy.engine import URL, Connection
35
20
  from sqlalchemy.orm import sessionmaker, Session
36
21
 
37
22
  from config import CONFIG
@@ -40,9 +25,17 @@ from config import CONFIG
40
25
  class DB:
41
26
  def __init__(self):
42
27
  db = CONFIG.env.db
43
- self._engine = create_engine(
44
- f"postgresql://{db.user}:{db.password}@{db.host}:{db.port}/{db.name}"
45
- ).connect()
28
+ # URL.create escapes the credentials, so a password with @, / or : in it
29
+ # can't break the connection string.
30
+ url = URL.create(
31
+ "postgresql",
32
+ username=db.user,
33
+ password=db.password,
34
+ host=db.host,
35
+ port=db.port,
36
+ database=db.name,
37
+ )
38
+ self._engine = create_engine(url).connect()
46
39
  Session = sessionmaker(bind=self.engine)
47
40
  self._session = Session()
48
41
 
@@ -2,7 +2,8 @@
2
2
  @File: logger.py
3
3
  @Version: 1.1
4
4
 
5
- Logger wrapper to manage logs in one place with log rotate
5
+ Logger wrapper to manage logs in one place with log rotate. Records also go to
6
+ stdout, so `docker logs` shows them.
6
7
 
7
8
  Logger('daily') will create daily.log file in the logs folder
8
9
  Logger('my.daily') will create my.daily.log file in the logs folder
@@ -35,6 +36,7 @@ except Exception as e:
35
36
  import logging
36
37
  import os
37
38
  import pathlib
39
+ import sys
38
40
  from logging import handlers
39
41
 
40
42
  from config import CONFIG
@@ -49,7 +51,7 @@ class Logger(object):
49
51
  if module is not None:
50
52
  directory = f"{directory}/{module}"
51
53
  logger_name = f"{module}_{name}"
52
- pathlib.Path(directory).mkdir(parents=True, exist_ok=True)
54
+ pathlib.Path(directory).mkdir(parents=True, exist_ok=True)
53
55
 
54
56
  filename = f"{directory}/{name}.log"
55
57
  formatter = "%(asctime)s %(levelname)-10s %(message)s"
@@ -74,6 +76,11 @@ class Logger(object):
74
76
  handler.setFormatter(formatter)
75
77
  self.logger.addHandler(handler)
76
78
 
79
+ console = logging.StreamHandler(sys.stdout)
80
+ console.setLevel(logging.INFO)
81
+ console.setFormatter(formatter)
82
+ self.logger.addHandler(console)
83
+
77
84
  def debug(self, message, exc_info=None):
78
85
  self.__call(self.logger.debug, message, exc_info)
79
86
 
@@ -3,7 +3,7 @@ from bullmq import Worker, Job
3
3
  from urllib.parse import urlparse, quote, urlencode
4
4
 
5
5
  from config import CONFIG, Redis
6
- from utils.db import DB
6
+ from utils.db import DB # @feature database
7
7
  from utils.logger import Logger
8
8
  from utils.telemetry import job_span # @feature telemetry
9
9
 
@@ -84,7 +84,7 @@ class JobUtil:
84
84
  await self._job.updateProgress(progress)
85
85
  await self._job.log(message)
86
86
 
87
- async def finalize(self, db: DB | None = None):
87
+ async def finalize(self, db: "DB | None" = None):
88
88
  if db:
89
89
  db.close()
90
90
  await self._job.updateProgress(100)
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
+ import { writeFileSync } from 'node:fs';
2
3
 
3
4
  // Pulls this app's env from Infisical into the matching .env file.
4
5
  //
@@ -42,6 +43,11 @@ const env = normalizeEnv(parseEnvArg());
42
43
  const envFile = ENV_FILES[env];
43
44
 
44
45
  console.log(`Fetching secrets (${env}) -> ${envFile}...`);
45
- execSync(`infisical export --projectId="${PROJECT_ID}" --path="${SECRET_PATH}" --env=${env} > ${envFile}`, {
46
- stdio: 'inherit'
46
+ // Export first and write only on success: a shell `>` redirect would empty
47
+ // the existing file before infisical had the chance to fail. execSync throws
48
+ // (and the process exits non-zero) when infisical fails.
49
+ const output = execSync(`infisical export --projectId="${PROJECT_ID}" --path="${SECRET_PATH}" --env=${env}`, {
50
+ stdio: ['inherit', 'pipe', 'inherit']
47
51
  });
52
+ writeFileSync(envFile, output);
53
+ console.log(`Wrote ${envFile}`);
@@ -47,6 +47,9 @@ jobs:
47
47
  VITE_APP_API_URL=${{ secrets.VITE_APP_API_URL }}
48
48
  VITE_APP_SITE_URL=${{ secrets.VITE_APP_SITE_URL }}
49
49
  VITE_APP_SUPPORT_EMAIL=${{ secrets.VITE_APP_SUPPORT_EMAIL }}
50
+ VITE_OPENOBSERVE_URL=${{ secrets.VITE_OPENOBSERVE_URL }} # @feature telemetry
51
+ VITE_OPENOBSERVE_ORG=${{ secrets.VITE_OPENOBSERVE_ORG }} # @feature telemetry
52
+ VITE_OPENOBSERVE_CLIENT_TOKEN=${{ secrets.VITE_OPENOBSERVE_CLIENT_TOKEN }} # @feature telemetry
50
53
  tags: |
51
54
  ${{ secrets.REGISTRY }}/${{ steps.tag.outputs.OWNER }}/web:latest
52
55
  ${{ secrets.REGISTRY }}/${{ steps.tag.outputs.OWNER }}/web:${{ steps.tag.outputs.TAG }}
@@ -35,6 +35,16 @@ ARG VITE_APP_SUPPORT_EMAIL
35
35
  ENV VITE_APP_API_URL=$VITE_APP_API_URL \
36
36
  VITE_APP_SITE_URL=$VITE_APP_SITE_URL \
37
37
  VITE_APP_SUPPORT_EMAIL=$VITE_APP_SUPPORT_EMAIL
38
+ # @feature:start telemetry
39
+ # Browser monitoring. Leave unset and the bundle ships with telemetry off. The
40
+ # client token is a RUM token, write-only and meant to be public.
41
+ ARG VITE_OPENOBSERVE_URL
42
+ ARG VITE_OPENOBSERVE_ORG
43
+ ARG VITE_OPENOBSERVE_CLIENT_TOKEN
44
+ ENV VITE_OPENOBSERVE_URL=$VITE_OPENOBSERVE_URL \
45
+ VITE_OPENOBSERVE_ORG=$VITE_OPENOBSERVE_ORG \
46
+ VITE_OPENOBSERVE_CLIENT_TOKEN=$VITE_OPENOBSERVE_CLIENT_TOKEN
47
+ # @feature:end
38
48
 
39
49
  # Fail loudly rather than silently producing a bundle with `undefined` URLs baked
40
50
  # in — that failure is invisible until the app 404s at runtime.
@@ -22,6 +22,7 @@ dev-dist/
22
22
  .env.*.local
23
23
  .env.test
24
24
  .env.production
25
+ .env.staging
25
26
 
26
27
  # editor / os
27
28
  .DS_Store
@@ -38,7 +38,9 @@ read_var() {
38
38
  }
39
39
 
40
40
  BUILD_ARGS=""
41
- for VAR in VITE_APP_API_URL VITE_APP_SITE_URL VITE_APP_SUPPORT_EMAIL; do
41
+ VARS="VITE_APP_API_URL VITE_APP_SITE_URL VITE_APP_SUPPORT_EMAIL"
42
+ VARS="$VARS VITE_OPENOBSERVE_URL VITE_OPENOBSERVE_ORG VITE_OPENOBSERVE_CLIENT_TOKEN" # @feature telemetry
43
+ for VAR in $VARS; do
42
44
  VALUE="$(read_var "$VAR")"
43
45
  [ -n "$VALUE" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $VAR=$VALUE"
44
46
  done
@@ -127,7 +127,7 @@
127
127
  },
128
128
  "pwa": {
129
129
  "title": "Installable PWA",
130
- "description": "vite-plugin-pwa: app-shell offline caching and an update prompt rather than a silent takeover.",
130
+ "description": "vite-plugin-pwa: app-shell offline caching, and registerType 'prompt' so a new build waits for the user instead of swapping assets under a running app. The update prompt and install button are yours to build with the plugin's useRegisterSW.",
131
131
  "packageJson": {
132
132
  "devDependencies": [
133
133
  "vite-plugin-pwa"
@@ -136,7 +136,7 @@
136
136
  },
137
137
  "charts": {
138
138
  "title": "Charts",
139
- "description": "ApexCharts via react-apexcharts, theme-aware through the shared chart palette.",
139
+ "description": "ApexCharts and react-apexcharts, with a theme-aware chart palette (the --chart-* tokens in the UI kit). No chart components ship.",
140
140
  "packageJson": {
141
141
  "dependencies": [
142
142
  "apexcharts",
@@ -1,12 +1,11 @@
1
- # Builds and pushes all four frontend apps from one Dockerfile.
1
+ # Builds and pushes every app's image from one Dockerfile.
2
2
  #
3
- # Unlike web-backend / web-coming-soon this repo produces FOUR images, so the
4
- # tag is generated once in a separate job and shared by every matrix leg —
3
+ # The tag is generated once in a separate job and shared by every matrix leg;
5
4
  # otherwise each app would get its own random tag and there would be no way to
6
5
  # deploy a matching set.
7
6
  #
8
7
  # Vite compiles the VITE_* URLs into each bundle at build time, so these images
9
- # are specific to one environment. See frontend/docker/README.md.
8
+ # are specific to one environment.
10
9
 
11
10
  name: Build and Push
12
11
 
@@ -33,12 +32,10 @@ jobs:
33
32
  DATE=$(date +'%y_%m_%d')
34
33
  RAND=$(printf '%05d' "$((RANDOM % 100000))")
35
34
  echo "TAG=${DATE}_${RAND}" >> "$GITHUB_OUTPUT"
36
- # Derived from `github.repository` (which web-backend's workflow already
37
- # relies on) rather than `github.repository_owner`, so this doesn't
38
- # depend on a context variable Forgejo may not populate. This repo is
39
- # `acme-corp/web-frontend`, so OWNER=acme-corp and the
40
- # images land as <registry>/acme-corp/web-<app>, matching
41
- # stack/frontend.compose.yml.
35
+ # Derived from `github.repository` rather than
36
+ # `github.repository_owner`, so this doesn't depend on a context
37
+ # variable Forgejo may not populate. Images land as
38
+ # <registry>/<owner>/web-<app>.
42
39
  echo "OWNER=$(echo '${{ github.repository }}' | cut -d/ -f1)" >> "$GITHUB_OUTPUT"
43
40
 
44
41
  build-and-push:
@@ -49,7 +46,11 @@ jobs:
49
46
  # failure. Note this means a partial set can be pushed — re-run the job.
50
47
  fail-fast: false
51
48
  matrix:
52
- app: [landing, auth, client, admin]
49
+ app:
50
+ - landing # @feature app-landing
51
+ - auth # @feature app-auth
52
+ - client # @feature app-client
53
+ - admin # @feature app-admin
53
54
 
54
55
  steps:
55
56
  - name: Checkout repository
@@ -83,6 +84,9 @@ jobs:
83
84
  VITE_APP_LANDING=${{ secrets.VITE_APP_LANDING }}
84
85
  VITE_APP_SUPPORT_EMAIL=${{ secrets.VITE_APP_SUPPORT_EMAIL }}
85
86
  VITE_APP_SITE_URL=${{ secrets.VITE_APP_SITE_URL }}
87
+ VITE_OPENOBSERVE_URL=${{ secrets.VITE_OPENOBSERVE_URL }} # @feature telemetry
88
+ VITE_OPENOBSERVE_ORG=${{ secrets.VITE_OPENOBSERVE_ORG }} # @feature telemetry
89
+ VITE_OPENOBSERVE_CLIENT_TOKEN=${{ secrets.VITE_OPENOBSERVE_CLIENT_TOKEN }} # @feature telemetry
86
90
  tags: |
87
91
  ${{ secrets.REGISTRY }}/${{ needs.meta.outputs.owner }}/web-${{ matrix.app }}:latest
88
92
  ${{ secrets.REGISTRY }}/${{ needs.meta.outputs.owner }}/web-${{ matrix.app }}:${{ needs.meta.outputs.tag }}
@@ -9,24 +9,28 @@
9
9
  # IMPORTANT: Vite inlines `VITE_*` values into the JS bundle at BUILD time, so a
10
10
  # built image is pinned to one environment. The URLs below are build args, not
11
11
  # runtime env — setting them in compose does nothing. Build one image per
12
- # environment, or move to runtime config (see docker/README.md).
12
+ # environment.
13
13
 
14
14
  # ---------- deps ----------
15
15
  # Manifests only, so `yarn install` re-runs solely when a dependency changes.
16
16
  FROM aurostack.dev/wesnetech/nodejs:24 AS deps
17
17
  WORKDIR /repo
18
18
 
19
+ # One line per workspace: every workspace in the project must be here (yarn
20
+ # install --immutable fails on a lockfile naming a missing one), and nothing
21
+ # else (COPY of a missing file fails the build).
19
22
  COPY package.json yarn.lock .yarnrc.yml turbo.json ./
20
- COPY apps/admin/package.json apps/admin/
21
- COPY apps/auth/package.json apps/auth/
22
- COPY apps/client/package.json apps/client/
23
- COPY apps/landing/package.json apps/landing/
23
+ COPY apps/admin/package.json apps/admin/ # @feature app-admin
24
+ COPY apps/auth/package.json apps/auth/ # @feature app-auth
25
+ COPY apps/client/package.json apps/client/ # @feature app-client
26
+ COPY apps/landing/package.json apps/landing/ # @feature app-landing
24
27
  COPY packages/api/package.json packages/api/
25
28
  COPY packages/auth/package.json packages/auth/
26
29
  COPY packages/config/package.json packages/config/
27
- COPY packages/hooks/package.json packages/hooks/
30
+ COPY packages/hooks/package.json packages/hooks/ # @feature hooks
28
31
  COPY packages/layouts/package.json packages/layouts/
29
- COPY packages/types/package.json packages/types/
32
+ COPY packages/telemetry/package.json packages/telemetry/ # @feature telemetry
33
+ COPY packages/types/package.json packages/types/ # @feature types
30
34
  COPY packages/ui/package.json packages/ui/
31
35
 
32
36
  RUN corepack enable && yarn install --immutable
@@ -56,6 +60,16 @@ ARG VITE_APP_SUPPORT_EMAIL
56
60
  # generated sitemap.xml. Must be a build arg — .dockerignore excludes .env, so a
57
61
  # local value never reaches the image.
58
62
  ARG VITE_APP_SITE_URL
63
+ # @feature:start telemetry
64
+ # Browser monitoring. Leave unset and the bundle ships with telemetry off. The
65
+ # client token is a RUM token, write-only and meant to be public.
66
+ ARG VITE_OPENOBSERVE_URL
67
+ ARG VITE_OPENOBSERVE_ORG
68
+ ARG VITE_OPENOBSERVE_CLIENT_TOKEN
69
+ ENV VITE_OPENOBSERVE_URL=$VITE_OPENOBSERVE_URL \
70
+ VITE_OPENOBSERVE_ORG=$VITE_OPENOBSERVE_ORG \
71
+ VITE_OPENOBSERVE_CLIENT_TOKEN=$VITE_OPENOBSERVE_CLIENT_TOKEN
72
+ # @feature:end
59
73
  ENV VITE_APP_API_URL=$VITE_APP_API_URL \
60
74
  VITE_APP_AUTH_HOST=$VITE_APP_AUTH_HOST \
61
75
  VITE_APP_CLIENT_HOST=$VITE_APP_CLIENT_HOST \
@@ -22,6 +22,7 @@ dist/
22
22
  .env.*.local
23
23
  .env.test
24
24
  .env.production
25
+ .env.staging
25
26
 
26
27
  # editor / os
27
28
  .DS_Store
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
@@ -52,6 +52,11 @@ const env = normalizeEnv(parseEnvArg());
52
52
  const envFile = ENV_FILES[env];
53
53
 
54
54
  console.log(`Fetching secrets for '${slug}' (${env}) -> ${envFile}...`);
55
- execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env} > ${envFile}`, {
56
- stdio: 'inherit'
55
+ // Export first and write only on success: a shell `>` redirect would empty
56
+ // the existing file before infisical had the chance to fail. execSync throws
57
+ // (and the process exits non-zero) when infisical fails.
58
+ const output = execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env}`, {
59
+ stdio: ['inherit', 'pipe', 'inherit']
57
60
  });
61
+ writeFileSync(envFile, output);
62
+ console.log(`Wrote ${envFile}`);
@@ -1,7 +1,7 @@
1
1
  import { Suspense, lazy, type ReactNode } from 'react';
2
2
  import { createBrowserRouter } from 'react-router';
3
- import { ProtectedRoute, signOut, useAuth } from '@acme/auth';
4
- import { AppShell, AppShellSkeleton, ErrorBoundary, NotFound, RouteFallback } from '@acme/layouts';
3
+ import { RoleRoute, signOut, useAuth } from '@acme/auth';
4
+ import { AppShell, AppShellSkeleton, ErrorBoundary, Forbidden, NotFound, RouteFallback } from '@acme/layouts';
5
5
  import { NAV_ITEMS } from './nav';
6
6
  import { HomeRoute } from '../routes/home';
7
7
 
@@ -15,8 +15,8 @@ const lazyRoute = (element: ReactNode) => <Suspense fallback={<RouteFallback />}
15
15
 
16
16
  /**
17
17
  * The shell reads the session itself rather than taking it as a prop —
18
- * `ProtectedRoute` has already established the user is authenticated by the
19
- * time this renders.
18
+ * `RoleRoute` has already established the user is signed in with an admin role
19
+ * by the time this renders.
20
20
  *
21
21
  * `ErrorBoundary` wraps the shell rather than being passed as the route's
22
22
  * `errorElement`: it is a plain React error boundary taking `children`, so it
@@ -35,11 +35,15 @@ export const router = createBrowserRouter([
35
35
  {
36
36
  // `fallback` is required and must not be blank: the session round trip is
37
37
  // cross-origin and takes most of a second, and rendering nothing for that
38
- // long reads as a broken app.
38
+ // long reads as a broken app. Signed-out visitors go to the auth app;
39
+ // signed-in users without an admin role see Forbidden.
40
+ //
41
+ // The gate is UI, not security: every endpoint this console calls must
42
+ // enforce the same role on the API.
39
43
  element: (
40
- <ProtectedRoute fallback={<AppShellSkeleton />}>
44
+ <RoleRoute allow={['admin', 'superuser']} fallback={<AppShellSkeleton />} forbidden={<Forbidden />}>
41
45
  <Shell />
42
- </ProtectedRoute>
46
+ </RoleRoute>
43
47
  ),
44
48
  children: [
45
49
  { index: true, element: <HomeRoute /> },
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
@@ -52,6 +52,11 @@ const env = normalizeEnv(parseEnvArg());
52
52
  const envFile = ENV_FILES[env];
53
53
 
54
54
  console.log(`Fetching secrets for '${slug}' (${env}) -> ${envFile}...`);
55
- execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env} > ${envFile}`, {
56
- stdio: 'inherit'
55
+ // Export first and write only on success: a shell `>` redirect would empty
56
+ // the existing file before infisical had the chance to fail. execSync throws
57
+ // (and the process exits non-zero) when infisical fails.
58
+ const output = execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env}`, {
59
+ stdio: ['inherit', 'pipe', 'inherit']
57
60
  });
61
+ writeFileSync(envFile, output);
62
+ console.log(`Wrote ${envFile}`);
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
@@ -52,6 +52,11 @@ const env = normalizeEnv(parseEnvArg());
52
52
  const envFile = ENV_FILES[env];
53
53
 
54
54
  console.log(`Fetching secrets for '${slug}' (${env}) -> ${envFile}...`);
55
- execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env} > ${envFile}`, {
56
- stdio: 'inherit'
55
+ // Export first and write only on success: a shell `>` redirect would empty
56
+ // the existing file before infisical had the chance to fail. execSync throws
57
+ // (and the process exits non-zero) when infisical fails.
58
+ const output = execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env}`, {
59
+ stdio: ['inherit', 'pipe', 'inherit']
57
60
  });
61
+ writeFileSync(envFile, output);
62
+ console.log(`Wrote ${envFile}`);
@@ -1,6 +1,6 @@
1
1
  /// <reference types="vite/client" />
2
2
  /// <reference types="vite-plugin-svgr/client" />
3
- /// <reference types="vite-plugin-pwa/react" />
3
+ /// <reference types="vite-plugin-pwa/react" /> // @feature pwa
4
4
 
5
5
  interface ImportMetaEnv {
6
6
  VITE_APP_API_URL: string;
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
@@ -52,6 +52,11 @@ const env = normalizeEnv(parseEnvArg());
52
52
  const envFile = ENV_FILES[env];
53
53
 
54
54
  console.log(`Fetching secrets for '${slug}' (${env}) -> ${envFile}...`);
55
- execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env} > ${envFile}`, {
56
- stdio: 'inherit'
55
+ // Export first and write only on success: a shell `>` redirect would empty
56
+ // the existing file before infisical had the chance to fail. execSync throws
57
+ // (and the process exits non-zero) when infisical fails.
58
+ const output = execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env}`, {
59
+ stdio: ['inherit', 'pipe', 'inherit']
57
60
  });
61
+ writeFileSync(envFile, output);
62
+ console.log(`Wrote ${envFile}`);
@@ -46,7 +46,9 @@ read_var() {
46
46
  }
47
47
 
48
48
  BUILD_ARGS="--build-arg APP=$APP"
49
- for VAR in VITE_APP_API_URL VITE_APP_AUTH_HOST VITE_APP_CLIENT_HOST VITE_APP_ADMIN_HOST VITE_APP_LANDING VITE_APP_SUPPORT_EMAIL; do
49
+ VARS="VITE_APP_API_URL VITE_APP_AUTH_HOST VITE_APP_CLIENT_HOST VITE_APP_ADMIN_HOST VITE_APP_LANDING VITE_APP_SUPPORT_EMAIL VITE_APP_SITE_URL"
50
+ VARS="$VARS VITE_OPENOBSERVE_URL VITE_OPENOBSERVE_ORG VITE_OPENOBSERVE_CLIENT_TOKEN" # @feature telemetry
51
+ for VAR in $VARS; do
50
52
  VALUE="$(read_var "$VAR")"
51
53
  [ -n "$VALUE" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $VAR=$VALUE"
52
54
  done
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
@@ -52,6 +52,11 @@ const env = normalizeEnv(parseEnvArg());
52
52
  const envFile = ENV_FILES[env];
53
53
 
54
54
  console.log(`Fetching secrets for '${slug}' (${env}) -> ${envFile}...`);
55
- execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env} > ${envFile}`, {
56
- stdio: 'inherit'
55
+ // Export first and write only on success: a shell `>` redirect would empty
56
+ // the existing file before infisical had the chance to fail. execSync throws
57
+ // (and the process exits non-zero) when infisical fails.
58
+ const output = execSync(`infisical export --projectId="${PROJECT_ID}" --path="${secretPath}" --env=${env}`, {
59
+ stdio: ['inherit', 'pipe', 'inherit']
57
60
  });
61
+ writeFileSync(envFile, output);
62
+ console.log(`Wrote ${envFile}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://turbo.build/schema.json",
3
3
  "ui": "tui",
4
- "globalDependencies": [".env", "tsconfig.base.json"],
4
+ "globalDependencies": [".env", "packages/config/tsconfig/base.json"],
5
5
  "tasks": {
6
6
  "build": {
7
7
  "dependsOn": ["^build"],
@@ -145,14 +145,14 @@
145
145
  },
146
146
  "app-admin": {
147
147
  "title": "Admin console",
148
- "description": "Role-gated internal console over the same data layer.",
148
+ "description": "Internal console over the same data layer, gated to the admin and superuser roles in the UI. The API must enforce the role on every endpoint it calls.",
149
149
  "files": [
150
150
  "apps/admin/**"
151
151
  ]
152
152
  },
153
153
  "app-landing": {
154
154
  "title": "Marketing site",
155
- "description": "Public landing site with a prerender step for static routes.",
155
+ "description": "Public marketing site: a home page, an SEO route table, and build-time robots.txt, sitemap.xml and security.txt.",
156
156
  "files": [
157
157
  "apps/landing/**"
158
158
  ]
@@ -173,7 +173,7 @@
173
173
  },
174
174
  "pwa": {
175
175
  "title": "Installable PWA (client app)",
176
- "description": "vite-plugin-pwa: app-shell offline caching, auto-update with an update prompt, and a custom install button.",
176
+ "description": "vite-plugin-pwa configured for the client app: app-shell offline caching, and registerType 'prompt' so a new build waits for the user instead of swapping assets under a running app. The update prompt and install button are yours to build with the plugin's useRegisterSW.",
177
177
  "requires": [
178
178
  "app-client"
179
179
  ],
@@ -186,7 +186,7 @@
186
186
  },
187
187
  "charts": {
188
188
  "title": "Charts (client app)",
189
- "description": "ApexCharts via react-apexcharts, theme-aware through the shared chart palette.",
189
+ "description": "ApexCharts and react-apexcharts in the client app, with a theme-aware chart palette (the --chart-* tokens in the UI kit). No chart components ship.",
190
190
  "requires": [
191
191
  "app-client"
192
192
  ],
@@ -239,11 +239,43 @@
239
239
  "apps/*/.bin/**",
240
240
  "packages/types/.bin/**"
241
241
  ],
242
- "packageJson": {
243
- "scripts": [
244
- "secrets"
245
- ]
246
- }
242
+ "packageJson": [
243
+ {
244
+ "scripts": [
245
+ "secrets"
246
+ ]
247
+ },
248
+ {
249
+ "file": "apps/admin/package.json",
250
+ "scripts": [
251
+ "secrets"
252
+ ]
253
+ },
254
+ {
255
+ "file": "apps/auth/package.json",
256
+ "scripts": [
257
+ "secrets"
258
+ ]
259
+ },
260
+ {
261
+ "file": "apps/client/package.json",
262
+ "scripts": [
263
+ "secrets"
264
+ ]
265
+ },
266
+ {
267
+ "file": "apps/landing/package.json",
268
+ "scripts": [
269
+ "secrets"
270
+ ]
271
+ },
272
+ {
273
+ "file": "packages/types/package.json",
274
+ "scripts": [
275
+ "secrets"
276
+ ]
277
+ }
278
+ ]
247
279
  }
248
280
  },
249
281
  "hooks": [