vibecarbon 0.1.7 → 0.2.1

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 (109) hide show
  1. package/carbon/.env.example +3 -2
  2. package/carbon/DEVELOPMENT.md +18 -3
  3. package/carbon/PRODUCTION.md +1 -1
  4. package/carbon/content/docs/cli.mdx +3 -3
  5. package/carbon/content/docs/deployment.mdx +1 -1
  6. package/carbon/content/docs/development.mdx +1 -1
  7. package/carbon/content/docs/getting-started.mdx +0 -1
  8. package/carbon/docker-compose.yml +6 -1
  9. package/carbon/scripts/dev-init.js +1 -1
  10. package/carbon/scripts/generate-dev-configs.sh +5 -7
  11. package/carbon/scripts/validate-dev-configs.sh +5 -7
  12. package/carbon/src/client/components/TechStackSection.tsx +0 -68
  13. package/carbon/src/client/index.css +0 -14
  14. package/carbon/src/client/locales/de.json +0 -18
  15. package/carbon/src/client/locales/en.json +86 -36
  16. package/carbon/src/client/locales/es.json +0 -18
  17. package/carbon/src/client/locales/fr.json +0 -18
  18. package/carbon/src/client/locales/pt.json +0 -18
  19. package/carbon/src/client/pages/Home.tsx +0 -3
  20. package/package.json +4 -2
  21. package/services/index.json +29 -0
  22. package/services/metabase/compose/docker-compose.override.yml +14 -0
  23. package/services/metabase/compose/docker-compose.prod.yml +29 -0
  24. package/services/metabase/compose/docker-compose.yml +68 -0
  25. package/services/metabase/db/init.sh +29 -0
  26. package/services/metabase/docs/integration.md +191 -0
  27. package/services/metabase/k8s/deployment.yaml +88 -0
  28. package/services/metabase/k8s/ingressroute.yaml +22 -0
  29. package/services/metabase/k8s/kustomization.yaml +9 -0
  30. package/services/metabase/k8s/network-policy.yaml +74 -0
  31. package/services/metabase/k8s/pvc.yaml +12 -0
  32. package/services/metabase/k8s/service.yaml +15 -0
  33. package/services/metabase/manifest.json +34 -0
  34. package/services/metabase/scripts/setup.sh +152 -0
  35. package/services/n8n/compose/docker-compose.override.yml +14 -0
  36. package/services/n8n/compose/docker-compose.prod.yml +32 -0
  37. package/services/n8n/compose/docker-compose.yml +99 -0
  38. package/services/n8n/db/init.sh +25 -0
  39. package/services/n8n/docs/SSO.md +124 -0
  40. package/services/n8n/docs/integration.md +155 -0
  41. package/services/n8n/hooks/hooks.js +131 -0
  42. package/services/n8n/k8s/deployment.yaml +116 -0
  43. package/services/n8n/k8s/ingressroute.yaml +24 -0
  44. package/services/n8n/k8s/kustomization.yaml +9 -0
  45. package/services/n8n/k8s/network-policy.yaml +82 -0
  46. package/services/n8n/k8s/pvc.yaml +14 -0
  47. package/services/n8n/k8s/service.yaml +14 -0
  48. package/services/n8n/manifest.json +36 -0
  49. package/services/n8n/scripts/setup.sh +42 -0
  50. package/{carbon/docker-compose.observability.prod.yml → services/observability/compose/docker-compose.prod.yml} +8 -0
  51. package/services/observability/docs/integration.md +31 -0
  52. package/services/observability/k8s/grafana-deployment.yaml +107 -0
  53. package/services/observability/k8s/grafana-provisioning-configmap.yaml +65 -0
  54. package/services/observability/k8s/grafana-pvc.yaml +14 -0
  55. package/services/observability/k8s/grafana-service.yaml +16 -0
  56. package/services/observability/k8s/ingressroute.yaml +22 -0
  57. package/services/observability/k8s/kustomization.yaml +24 -0
  58. package/services/observability/k8s/loki-configmap.yaml +74 -0
  59. package/services/observability/k8s/loki-deployment.yaml +72 -0
  60. package/services/observability/k8s/loki-pvc.yaml +14 -0
  61. package/services/observability/k8s/loki-service.yaml +16 -0
  62. package/services/observability/k8s/network-policy.yaml +141 -0
  63. package/services/observability/k8s/prometheus-configmap.yaml +80 -0
  64. package/services/observability/k8s/prometheus-deployment.yaml +78 -0
  65. package/services/observability/k8s/prometheus-pvc.yaml +14 -0
  66. package/services/observability/k8s/prometheus-rbac.yaml +55 -0
  67. package/services/observability/k8s/prometheus-service.yaml +16 -0
  68. package/services/observability/manifest.json +28 -0
  69. package/services/redis/compose/docker-compose.prod.yml +10 -0
  70. package/services/redis/compose/docker-compose.yml +24 -0
  71. package/services/redis/docs/integration.md +148 -0
  72. package/services/redis/k8s/deployment.yaml +70 -0
  73. package/services/redis/k8s/kustomization.yaml +8 -0
  74. package/services/redis/k8s/network-policy.yaml +20 -0
  75. package/services/redis/k8s/pvc.yaml +12 -0
  76. package/services/redis/k8s/service.yaml +15 -0
  77. package/services/redis/manifest.json +30 -0
  78. package/services/s3/compose/docker-compose.s3-ha.yml +86 -0
  79. package/services/s3/compose/docker-compose.s3.yml +35 -0
  80. package/services/s3/manifest.json +87 -0
  81. package/src/add.js +57 -89
  82. package/src/cli.js +1 -1
  83. package/src/create.js +18 -87
  84. package/src/destroy.js +23 -1
  85. package/src/lib/deploy/bundle.js +4 -2
  86. package/src/lib/deploy/compose/ha.js +11 -8
  87. package/src/lib/deploy/compose/index.js +45 -17
  88. package/src/lib/deploy/image.js +10 -2
  89. package/src/lib/deploy/k8s/k3s.js +23 -4
  90. package/src/lib/deploy/orchestrator.js +13 -1
  91. package/src/lib/deploy/remote-build.js +29 -8
  92. package/src/remove.js +9 -6
  93. package/src/scale.js +14 -1
  94. package/carbon/content/docs/optional-services.mdx +0 -160
  95. package/carbon/src/client/components/MetricsStrip.tsx +0 -48
  96. /package/{carbon/docker-compose.observability.override.yml → services/observability/compose/docker-compose.override.yml} +0 -0
  97. /package/{carbon/docker-compose.observability.yml → services/observability/compose/docker-compose.yml} +0 -0
  98. /package/{carbon → services/observability}/volumes/grafana/dashboards/logs.json +0 -0
  99. /package/{carbon → services/observability}/volumes/grafana/dashboards/overview.json +0 -0
  100. /package/{carbon → services/observability}/volumes/grafana/dashboards/postgresql.json +0 -0
  101. /package/{carbon → services/observability}/volumes/grafana/dashboards.dev/logs.json +0 -0
  102. /package/{carbon → services/observability}/volumes/grafana/dashboards.dev/overview.json +0 -0
  103. /package/{carbon → services/observability}/volumes/grafana/dashboards.dev/postgresql.json +0 -0
  104. /package/{carbon → services/observability}/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +0 -0
  105. /package/{carbon → services/observability}/volumes/grafana/provisioning/dashboards/dashboards.yml +0 -0
  106. /package/{carbon → services/observability}/volumes/grafana/provisioning/datasources/datasources.yml +0 -0
  107. /package/{carbon → services/observability}/volumes/loki/loki-config.yml +0 -0
  108. /package/{carbon → services/observability}/volumes/prometheus/prometheus.yml +0 -0
  109. /package/{carbon → services/observability}/volumes/promtail/promtail-config.yml +0 -0
package/src/add.js CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Vibecarbon Add Command
3
3
  *
4
- * Adds optional services to an existing Vibecarbon project. Services
5
- * are fetched from GitHub at runtime so users always get the latest
6
- * templates regardless of their CLI version. -offline falls back to
7
- * bundled templates for air-gapped environments.
4
+ * Adds optional services to an existing Vibecarbon project. Service
5
+ * bundles ship with the CLI and are installed from the packaged
6
+ * `services/` directory by default the same way `create` scaffolds
7
+ * from the packaged `carbon/` template, so an `add` is version-pinned
8
+ * and works offline. `-online` opts into fetching the latest bundles
9
+ * from GitHub (single-file assets only; directory assets such as `k8s/`
10
+ * still come from the packaged copy).
8
11
  *
9
12
  * Interactive-by-default: bare `vibecarbon add` prompts for a feature
10
13
  * to add. Positional features (`vibecarbon add observability redis`)
@@ -53,7 +56,9 @@ const GITHUB_BRANCH = 'main';
53
56
  const GITHUB_RAW_BASE = `https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}`;
54
57
  const SERVICES_PATH = 'services';
55
58
 
56
- // Local services directory (for --offline mode and development)
59
+ // Packaged services directory the default install source (shipped in the
60
+ // npm package `files`, alongside `carbon/`). `-online` overrides this to fetch
61
+ // from GitHub.
57
62
  const LOCAL_SERVICES_DIR = join(__dirname, '..', SERVICES_PATH);
58
63
 
59
64
  // Template variable placeholders
@@ -92,7 +97,7 @@ function buildSpec() {
92
97
  summary: 'Add a feature to a Vibecarbon project',
93
98
  description: [
94
99
  'Available features:',
95
- ...LOCAL_FEATURES.map((f) => ` ${f.name.padEnd(16)} ${f.description}`),
100
+ ...AVAILABLE_FEATURES.map((f) => ` ${f.name.padEnd(16)} ${f.description}`),
96
101
  '',
97
102
  'External services (CI/CD, Stripe, OAuth, etc.) live in `vibecarbon configure`.',
98
103
  ].join('\n'),
@@ -109,9 +114,9 @@ function buildSpec() {
109
114
  { name: 'v', boolean: true, description: 'Show version' },
110
115
  { name: 'y', boolean: true, description: 'Skip confirmation prompts' },
111
116
  {
112
- name: 'offline',
117
+ name: 'online',
113
118
  boolean: true,
114
- description: 'Use bundled templates (for air-gapped environments)',
119
+ description: 'Fetch the latest bundles from GitHub instead of the packaged copy',
115
120
  },
116
121
  ],
117
122
  examples: [
@@ -227,9 +232,13 @@ async function installServiceDirectory(serviceName, srcDir, destDir, variables,
227
232
  }
228
233
  }
229
234
  } else {
230
- // For online mode, the manifest should list all files
231
- // We'll handle this in the main install function
232
- throw new Error('Directory installation requires file listing in manifest');
235
+ // Online mode can't enumerate a remote directory (GitHub raw has no
236
+ // listing and we don't hit the contents API). Directory assets such as
237
+ // `k8s/` therefore only install from the packaged copy.
238
+ throw new Error(
239
+ `Cannot fetch directory "${srcDir}" over -online (remote directory listing is not supported). ` +
240
+ 'Drop -online to install this feature from the packaged template.',
241
+ );
233
242
  }
234
243
  }
235
244
 
@@ -290,88 +299,27 @@ function removeFromBaseKustomization(kustomizationEntry) {
290
299
  writeFileSync(kustomizationPath, content);
291
300
  }
292
301
 
293
- // ============================================================================
294
- // SPECIALIZED FEATURE HANDLERS
295
- // ============================================================================
296
-
297
- // Features with specialized handlers (not manifest-based). CI/CD used to be
298
- // here too; it moved to `vibecarbon configure` (CI/CD is an external service,
299
- // not a local container, so it lives with the other configure features).
300
- const SPECIALIZED_FEATURES = ['observability'];
301
-
302
- async function handleSpecializedFeature(featureName, options) {
303
- const { project } = options;
304
-
305
- switch (featureName) {
306
- case 'observability': {
307
- return handleObservabilityFeature(project);
308
- }
309
-
310
- default:
311
- throw new Error(`Unknown specialized feature: ${featureName}`);
312
- }
313
- }
314
-
315
- /**
316
- * Enable observability stack (Prometheus, Grafana, Loki)
317
- * Files are already included in the project - this just enables them
318
- */
319
- async function handleObservabilityFeature(_project) {
320
- const manifest = loadManifest();
321
-
322
- // Check if already enabled
323
- if (manifest.services?.observability) {
324
- p.log.info('Observability is already enabled.');
325
- return true;
326
- }
327
-
328
- // Check that the observability compose file exists
329
- const observabilityCompose = join(process.cwd(), 'docker-compose.observability.yml');
330
- if (!existsSync(observabilityCompose)) {
331
- p.log.error('Observability files not found. Please ensure your project is up to date.');
332
- return false;
333
- }
334
-
335
- const s = p.spinner();
336
- s.start('Enabling observability stack');
337
-
338
- // Set env flags for dashboard visibility (client + server)
339
- setEnvVar('VITE_OBSERVABILITY_ENABLED', 'true');
340
- setEnvVar('OBSERVABILITY_ENABLED', 'true');
341
-
342
- // Update manifest
343
- if (!manifest.services) manifest.services = {};
344
- manifest.services.observability = { addedAt: new Date().toISOString() };
345
- saveManifest(manifest);
346
-
347
- s.stop('Observability enabled');
348
-
349
- p.note(
350
- [
351
- 'Observability stack enabled:',
352
- ` ${c.info('Prometheus')} http://prometheus.localhost`,
353
- ` ${c.info('Grafana')} http://grafana.localhost`,
354
- ` ${c.info('Loki')} http://loki.localhost`,
355
- ].join('\n'),
356
- 'Observability',
357
- );
358
-
359
- return true;
360
- }
361
-
362
302
  // ============================================================================
363
303
  // ADD SERVICE
364
304
  // ============================================================================
365
305
 
366
306
  async function addService(serviceName, options) {
367
307
  const { project } = options;
368
- let { offline } = options;
369
308
 
370
- // Check if this is a specialized feature with a custom handler
371
- if (SPECIALIZED_FEATURES.includes(serviceName)) {
372
- return handleSpecializedFeature(serviceName, options);
309
+ // Parked features ship in `services/` but are turned off for this release.
310
+ // Refuse here — before any manifest fetch — so `add <parked>` can't install
311
+ // something we've hidden from the offering. `remove` is intentionally not
312
+ // guarded, so an existing install can still be cleaned up.
313
+ if (isParked(serviceName)) {
314
+ p.log.warn(`"${serviceName}" is not available in this release.`);
315
+ return false;
373
316
  }
374
317
 
318
+ // Default install source is the packaged `services/` dir (mirrors how
319
+ // `create` uses the packaged `carbon/` template). `-online` opts into the
320
+ // GitHub fetch; on network failure we fall back to the packaged copy below.
321
+ let offline = !options.online;
322
+
375
323
  let manifest;
376
324
  try {
377
325
  manifest = await fetchServiceManifest(serviceName, offline);
@@ -528,11 +476,18 @@ async function addService(serviceName, options) {
528
476
  // LIST SERVICES
529
477
  // ============================================================================
530
478
 
531
- // Features that work locally without external dependencies
479
+ // Features that work locally without external dependencies.
480
+ //
481
+ // `status: 'parked'` turns a feature off for this release: it's hidden from the
482
+ // offering (help body + interactive prompt) and `add` refuses to install it,
483
+ // but its packaged bundle under `services/` stays in place so un-parking is a
484
+ // one-line flip. Existing installs can still be `vibecarbon remove`d. Broad
485
+ // add-on discovery will move to a marketplace later — this list is not the
486
+ // permanent catalog.
532
487
  const LOCAL_FEATURES = [
533
488
  { name: 'observability', description: 'Prometheus, Grafana, Loki monitoring stack' },
534
- { name: 'n8n', description: 'Workflow automation platform' },
535
- { name: 'metabase', description: 'Business intelligence and analytics' },
489
+ { name: 'n8n', description: 'Workflow automation platform', status: 'parked' },
490
+ { name: 'metabase', description: 'Business intelligence and analytics', status: 'parked' },
536
491
  { name: 'redis', description: 'In-memory cache and session store' },
537
492
  ];
538
493
 
@@ -542,6 +497,19 @@ const LOCAL_FEATURES = [
542
497
 
543
498
  const ALL_FEATURES = [...LOCAL_FEATURES];
544
499
 
500
+ // Features actually offered to users. Parked features are excluded from the
501
+ // help body and the interactive prompt, but remain in ALL_FEATURES so typo
502
+ // suggestions and the parked guard can still resolve their names.
503
+ const AVAILABLE_FEATURES = LOCAL_FEATURES.filter((f) => f.status !== 'parked');
504
+
505
+ /**
506
+ * Whether a feature ships but is turned off for this release.
507
+ * @param {string} name
508
+ */
509
+ function isParked(name) {
510
+ return LOCAL_FEATURES.some((f) => f.name === name && f.status === 'parked');
511
+ }
512
+
545
513
  /**
546
514
  * Calculate Levenshtein distance between two strings
547
515
  */
@@ -652,7 +620,7 @@ async function main(cliArgs) {
652
620
  if (services.length === 0) {
653
621
  const choice = await p.select({
654
622
  message: 'Which feature do you want to add?',
655
- options: LOCAL_FEATURES.map((f) => ({
623
+ options: AVAILABLE_FEATURES.map((f) => ({
656
624
  value: f.name,
657
625
  label: f.name,
658
626
  hint: f.description,
@@ -668,7 +636,7 @@ async function main(cliArgs) {
668
636
  let success = true;
669
637
  for (const serviceName of services) {
670
638
  const result = await addService(serviceName, {
671
- offline: !!values.offline,
639
+ online: !!values.online,
672
640
  project,
673
641
  cliArgs: cliArgs, // Pass raw CLI args for infrastructure services
674
642
  });
package/src/cli.js CHANGED
@@ -95,7 +95,7 @@ ${c.bold('DEV COMMANDS')}
95
95
  ${c.info('down')} Stop local development environment
96
96
  ${c.info('status')} Show project and deployment status
97
97
  ${c.info('reset')} Reset local environment (removes all data)
98
- ${c.info('add')} [feature] Add features (cicd, observability, n8n, metabase, redis)
98
+ ${c.info('add')} [feature] Add features (observability, redis)
99
99
  ${c.info('remove')} [feature] Remove features from a project
100
100
  ${c.info('configure')} Configure external services (billing, OAuth, SMTP, etc.)
101
101
  ${c.info('upgrade')} Upgrade infrastructure files to latest template
package/src/create.js CHANGED
@@ -188,8 +188,8 @@ const SPEC = {
188
188
  description: 'non-interactive (CI/CD)',
189
189
  },
190
190
  {
191
- command: 'vibecarbon create my-saas -git -install',
192
- description: 'create + initialize git + run pm install',
191
+ command: 'vibecarbon create my-saas -install',
192
+ description: 'create + run pm install (git is initialized automatically)',
193
193
  },
194
194
  ],
195
195
  };
@@ -697,13 +697,6 @@ async function bootstrap(cliArgs) {
697
697
  'volumes/kong',
698
698
  'volumes/db',
699
699
  'volumes/traefik',
700
- 'volumes/prometheus',
701
- 'volumes/loki',
702
- 'volumes/promtail',
703
- 'volumes/grafana/provisioning/dashboards',
704
- 'volumes/grafana/provisioning/datasources',
705
- 'volumes/grafana/dashboards',
706
- 'volumes/grafana/dashboards.dev',
707
700
  '.github/workflows',
708
701
  'k8s/base/app',
709
702
  'k8s/base/backup',
@@ -758,21 +751,11 @@ async function bootstrap(cliArgs) {
758
751
  copyTemplate('.dockerignore', join(projectDir, '.dockerignore'), variables);
759
752
  copyTemplate('docker-compose.yml', join(projectDir, 'docker-compose.yml'), variables);
760
753
  copyTemplate('docker-compose.prod.yml', join(projectDir, 'docker-compose.prod.yml'), variables);
761
- copyTemplate(
762
- 'docker-compose.observability.yml',
763
- join(projectDir, 'docker-compose.observability.yml'),
764
- variables,
765
- );
766
754
  copyTemplate(
767
755
  'docker-compose.override.yml',
768
756
  join(projectDir, 'docker-compose.override.yml'),
769
757
  variables,
770
758
  );
771
- copyTemplate(
772
- 'docker-compose.observability.override.yml',
773
- join(projectDir, 'docker-compose.observability.override.yml'),
774
- variables,
775
- );
776
759
  copyTemplate('PRODUCTION.md', join(projectDir, 'PRODUCTION.md'), variables);
777
760
  copyTemplate('DEVELOPMENT.md', join(projectDir, 'DEVELOPMENT.md'), variables);
778
761
  copyTemplate(
@@ -780,11 +763,6 @@ async function bootstrap(cliArgs) {
780
763
  join(projectDir, 'docker-compose.dev-init.yml'),
781
764
  variables,
782
765
  );
783
- copyTemplate(
784
- 'docker-compose.observability.prod.yml',
785
- join(projectDir, 'docker-compose.observability.prod.yml'),
786
- variables,
787
- );
788
766
  chmodSync(join(projectDir, 'docker-entrypoint.sh'), 0o755);
789
767
 
790
768
  await tick();
@@ -802,6 +780,16 @@ async function bootstrap(cliArgs) {
802
780
  join(projectDir, 'volumes/db/set-passwords.sh'),
803
781
  variables,
804
782
  );
783
+ // wal-archive.sh is postgres's archive_command. It was missing from this copy
784
+ // list, so scaffolds shipped no source file — Docker then bind-mounted an
785
+ // empty DIRECTORY at /etc/postgresql/wal-archive.sh and postgres failed
786
+ // archiving with exit 126 ("is a directory"), re-pinning WAL. Copy it (the
787
+ // chmod block below makes it +x; the script has only shell $vars, no {{}}).
788
+ copyTemplate(
789
+ 'volumes/db/wal-archive.sh',
790
+ join(projectDir, 'volumes/db/wal-archive.sh'),
791
+ variables,
792
+ );
805
793
  copyTemplate('volumes/db/jwt.sql', join(projectDir, 'volumes/db/jwt.sql'), variables);
806
794
  copyTemplate('volumes/db/realtime.sql', join(projectDir, 'volumes/db/realtime.sql'), variables);
807
795
  copyTemplate(
@@ -830,68 +818,6 @@ async function bootstrap(cliArgs) {
830
818
  variables,
831
819
  );
832
820
 
833
- // Observability config files (used by deploy --observability)
834
- copyTemplate(
835
- 'volumes/prometheus/prometheus.yml',
836
- join(projectDir, 'volumes/prometheus/prometheus.yml'),
837
- variables,
838
- );
839
- copyTemplate(
840
- 'volumes/loki/loki-config.yml',
841
- join(projectDir, 'volumes/loki/loki-config.yml'),
842
- variables,
843
- );
844
- copyTemplate(
845
- 'volumes/promtail/promtail-config.yml',
846
- join(projectDir, 'volumes/promtail/promtail-config.yml'),
847
- variables,
848
- );
849
- copyTemplate(
850
- 'volumes/grafana/provisioning/dashboards/dashboards.yml',
851
- join(projectDir, 'volumes/grafana/provisioning/dashboards/dashboards.yml'),
852
- variables,
853
- );
854
- copyTemplate(
855
- 'volumes/grafana/provisioning/dashboards/dashboards.dev.yml',
856
- join(projectDir, 'volumes/grafana/provisioning/dashboards/dashboards.dev.yml'),
857
- variables,
858
- );
859
- copyTemplate(
860
- 'volumes/grafana/provisioning/datasources/datasources.yml',
861
- join(projectDir, 'volumes/grafana/provisioning/datasources/datasources.yml'),
862
- variables,
863
- );
864
- copyTemplate(
865
- 'volumes/grafana/dashboards/logs.json',
866
- join(projectDir, 'volumes/grafana/dashboards/logs.json'),
867
- variables,
868
- );
869
- copyTemplate(
870
- 'volumes/grafana/dashboards/overview.json',
871
- join(projectDir, 'volumes/grafana/dashboards/overview.json'),
872
- variables,
873
- );
874
- copyTemplate(
875
- 'volumes/grafana/dashboards/postgresql.json',
876
- join(projectDir, 'volumes/grafana/dashboards/postgresql.json'),
877
- variables,
878
- );
879
- copyTemplate(
880
- 'volumes/grafana/dashboards.dev/logs.json',
881
- join(projectDir, 'volumes/grafana/dashboards.dev/logs.json'),
882
- variables,
883
- );
884
- copyTemplate(
885
- 'volumes/grafana/dashboards.dev/overview.json',
886
- join(projectDir, 'volumes/grafana/dashboards.dev/overview.json'),
887
- variables,
888
- );
889
- copyTemplate(
890
- 'volumes/grafana/dashboards.dev/postgresql.json',
891
- join(projectDir, 'volumes/grafana/dashboards.dev/postgresql.json'),
892
- variables,
893
- );
894
-
895
821
  copyTemplate('backup/backup.sh', join(projectDir, 'backup/backup.sh'), variables);
896
822
  copyTemplate('backup/compose-backup.sh', join(projectDir, 'backup/compose-backup.sh'), variables);
897
823
  copyTemplate('backup/Dockerfile', join(projectDir, 'backup/Dockerfile'), variables);
@@ -938,6 +864,11 @@ async function bootstrap(cliArgs) {
938
864
  'docker-entrypoint.sh',
939
865
  'volumes/kong/docker-entrypoint.sh',
940
866
  'volumes/db/set-passwords.sh',
867
+ // Postgres execs this directly as its archive_command, so it MUST be +x.
868
+ // copyTemplate (copyFileSync) drops the source's exec bit, and omitting it
869
+ // here shipped a 0644 script → archive_command failed with exit 126 ("not
870
+ // executable") → WAL pinned → disk-fill risk the wrapper exists to prevent.
871
+ 'volumes/db/wal-archive.sh',
941
872
  ];
942
873
  for (const script of shellScripts) {
943
874
  const scriptPath = join(projectDir, script);
@@ -1180,7 +1111,7 @@ fi
1180
1111
  # Runs lint + unit + component + integration tests.
1181
1112
  #
1182
1113
  # No-ops gracefully if node_modules isn't present yet — a fresh
1183
- # \`vibecarbon create -git\` lands you on a commit that's pushable
1114
+ # \`vibecarbon create\` lands you on a commit that's pushable
1184
1115
  # before you've run \`pnpm install\`. Once deps land the hook gates
1185
1116
  # every subsequent push. Bypass at will with \`git push --no-verify\`.
1186
1117
 
package/src/destroy.js CHANGED
@@ -693,7 +693,29 @@ async function handleBackupBucket(envConfig, projectConfig, args, spinner) {
693
693
  }
694
694
  const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, region);
695
695
  try {
696
- const result = await s3Provider.emptyAndDeleteBucket(bucketName);
696
+ // Bound the purge so a slow/contended S3 endpoint can't consume the whole
697
+ // destroy budget. The Hetzner resources are already freed (Pulumi destroy
698
+ // ran before this), but a single S3 op can hang up to ~9 min under the
699
+ // SDK (maxAttempts 3 × 60s requestTimeout) × _send (maxAttempts 3) retry
700
+ // layers — observed on k8s teardown, where the storage bucket doubles as
701
+ // the Pulumi state backend (slow/contended), blowing the 600s
702
+ // final-destroy timeout and getting the whole step SIGKILLed. On timeout
703
+ // we leave the bucket for the orphan sweep / next destroy rather than hang.
704
+ const PURGE_TIMEOUT_MS = 180_000;
705
+ const result = await Promise.race([
706
+ s3Provider.emptyAndDeleteBucket(bucketName),
707
+ new Promise((_, reject) =>
708
+ setTimeout(
709
+ () =>
710
+ reject(
711
+ new Error(
712
+ `backup bucket purge exceeded ${PURGE_TIMEOUT_MS / 1000}s — left for the orphan sweep / a retried destroy`,
713
+ ),
714
+ ),
715
+ PURGE_TIMEOUT_MS,
716
+ ),
717
+ ),
718
+ ]);
697
719
  spinner.stop(
698
720
  result.deleted
699
721
  ? result.objectsRemoved > 0
@@ -96,7 +96,9 @@ function composeFileFlags(options = {}) {
96
96
  if (options.metabase) {
97
97
  files.push('-f', 'docker-compose.metabase.yml', '-f', 'docker-compose.metabase.prod.yml');
98
98
  }
99
- if (options.redis) files.push('-f', 'docker-compose.redis.yml');
99
+ if (options.redis) {
100
+ files.push('-f', 'docker-compose.redis.yml', '-f', 'docker-compose.redis.prod.yml');
101
+ }
100
102
  return files.join(' ');
101
103
  }
102
104
 
@@ -126,7 +128,7 @@ export function renderBundle(projectName, options = {}) {
126
128
  if (options.metabase) {
127
129
  rootFiles.push('docker-compose.metabase.yml', 'docker-compose.metabase.prod.yml');
128
130
  }
129
- if (options.redis) rootFiles.push('docker-compose.redis.yml');
131
+ if (options.redis) rootFiles.push('docker-compose.redis.yml', 'docker-compose.redis.prod.yml');
130
132
 
131
133
  for (const file of rootFiles) {
132
134
  const src = join(cwd, file);
@@ -1043,15 +1043,18 @@ export async function deployComposeHA(options) {
1043
1043
  ]),
1044
1044
  );
1045
1045
 
1046
- // Run migrations on primary only
1046
+ // Run migrations on primary only. Let failures propagate — silently shipping
1047
+ // an empty/partial schema is far worse than a visibly-failed deploy the
1048
+ // operator can retry. The old swallowing try/catch ("non-fatal") masked
1049
+ // exactly that: HA could deploy a schema-less DB that 500s on every DB-backed
1050
+ // route with no error surfaced. runMigrations now waits for supabase_admin AND
1051
+ // the storage schema (storage.buckets.public) before applying with
1052
+ // ON_ERROR_STOP=1, so the first-run races that motivated the swallow are gone
1053
+ // and a failure here is a real one that must abort the deploy.
1047
1054
  onProgress('Running database migrations on primary...');
1048
- try {
1049
- await perfAsync('deploy.ha.compose.migrations', () =>
1050
- runMigrations(primaryIp, sshKeyPath, projectName),
1051
- );
1052
- } catch {
1053
- // Migrations may partially fail on first run, non-fatal
1054
- }
1055
+ await perfAsync('deploy.ha.compose.migrations', () =>
1056
+ runMigrations(primaryIp, sshKeyPath, projectName),
1057
+ );
1055
1058
 
1056
1059
  // Create admin user in production Supabase (same as single-server deploy)
1057
1060
  onProgress('Creating admin user...');
@@ -493,7 +493,9 @@ function composeFileFlags(options = {}) {
493
493
  if (options.metabase) {
494
494
  files.push('-f', 'docker-compose.metabase.yml', '-f', 'docker-compose.metabase.prod.yml');
495
495
  }
496
- if (options.redis) files.push('-f', 'docker-compose.redis.yml');
496
+ if (options.redis) {
497
+ files.push('-f', 'docker-compose.redis.yml', '-f', 'docker-compose.redis.prod.yml');
498
+ }
497
499
  return files.join(' ');
498
500
  }
499
501
 
@@ -756,6 +758,27 @@ export async function runMigrations(ip, sshKeyPath, projectName) {
756
758
  { timeout: 240_000 },
757
759
  );
758
760
 
761
+ // Also wait for the storage service to finish ITS first-boot migrations
762
+ // before applying app migrations. supabase-storage adds columns to the
763
+ // `storage` schema on startup (notably storage.buckets.public); our
764
+ // 00001_init.sql seeds buckets referencing them. Racing ahead fails with
765
+ // `column "public" of relation "buckets" does not exist`, which aborts the
766
+ // whole migration — the core public.* tables get created first, but the
767
+ // deploy still fails (deployComposeHA only ever masked this by swallowing the
768
+ // error in a try/catch). The supabase_admin gate above is insufficient: that
769
+ // role exists before storage has migrated. Gate on storage.buckets.public
770
+ // being queryable as the proxy for "storage migrations done", polling ~3 min.
771
+ await sshRunAsync(
772
+ ip,
773
+ sshKeyPath,
774
+ `cd ${remoteDir} && ` +
775
+ `for i in $(seq 1 60); do ` +
776
+ `docker compose exec -T db psql -U supabase_admin -d postgres -c 'SELECT public FROM storage.buckets LIMIT 0' >/dev/null 2>&1 && exit 0; ` +
777
+ `echo "[migrate] waiting for storage schema (buckets.public) to be ready (attempt $i/60)"; sleep 3; ` +
778
+ `done; echo "[migrate] storage schema never became ready" >&2; exit 1`,
779
+ { timeout: 240_000 },
780
+ );
781
+
759
782
  // Apply each migration with ON_ERROR_STOP=1 and abort on the first failure.
760
783
  // A failed migration MUST fail the deploy — silently shipping an empty schema
761
784
  // is far worse than a deploy the operator can see failed and retry. Errors
@@ -771,6 +794,25 @@ export async function runMigrations(ip, sshKeyPath, projectName) {
771
794
  `done`,
772
795
  { timeout: 300_000 },
773
796
  );
797
+
798
+ // Reload PostgREST schema cache so it sees newly created tables. The old
799
+ // `docker compose exec rest kill -s SIGUSR1 1` never worked — the
800
+ // postgrest/postgrest image has no `kill` binary ("exec: kill: executable
801
+ // file not found in $PATH"), so the reload silently no-op'd and fresh tables
802
+ // stayed invisible (PGRST205) until rest happened to restart. RCA prod-1
803
+ // 2026-05-26. Use the canonical SQL NOTIFY on the pgrst channel instead
804
+ // (db-channel-enabled defaults on); fall back to restarting rest if NOTIFY
805
+ // doesn't land. Best-effort: a missed reload self-heals on the next deploy.
806
+ // Lives here (not just in deployCompose) so every caller — deployCompose,
807
+ // deployComposeHA, and the orchestrator's inline single-compose path — gets
808
+ // a queryable schema instead of PGRST205.
809
+ await sshRunAsync(
810
+ ip,
811
+ sshKeyPath,
812
+ `cd ${remoteDir} && ` +
813
+ `docker compose exec -T db psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'" 2>/dev/null || ` +
814
+ `docker compose restart rest 2>/dev/null || true`,
815
+ );
774
816
  }
775
817
 
776
818
  /**
@@ -837,23 +879,9 @@ export async function deployCompose(options) {
837
879
  // swallowing it (RCA prod-1 2026-05-26: the previous blanket try/catch hid a
838
880
  // fully-failed migration and shipped a schema-less prod that reported success).
839
881
  onProgress('Running database migrations...');
840
- const remoteDir = `/opt/${projectName}`;
882
+ // runMigrations applies supabase/migrations/* AND reloads PostgREST's schema
883
+ // cache, so the new tables are immediately queryable (no PGRST205).
841
884
  await runMigrations(ip, sshKeyPath, projectName);
842
- // Reload PostgREST schema cache so it sees newly created tables. The old
843
- // `docker compose exec rest kill -s SIGUSR1 1` never worked — the
844
- // postgrest/postgrest image has no `kill` binary ("exec: kill: executable
845
- // file not found in $PATH"), so the reload silently no-op'd and fresh tables
846
- // stayed invisible (PGRST205) until rest happened to restart. RCA prod-1
847
- // 2026-05-26. Use the canonical SQL NOTIFY on the pgrst channel instead
848
- // (db-channel-enabled defaults on); fall back to restarting rest if NOTIFY
849
- // doesn't land. Best-effort: a missed reload self-heals on the next deploy.
850
- await sshRunAsync(
851
- ip,
852
- sshKeyPath,
853
- `cd ${remoteDir} && ` +
854
- `docker compose exec -T db psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'" 2>/dev/null || ` +
855
- `docker compose restart rest 2>/dev/null || true`,
856
- );
857
885
 
858
886
  return {
859
887
  success: true,
@@ -92,11 +92,11 @@ export function inspectGitState(cwd) {
92
92
  * pod (Phase 1 wired the registries.yaml; Phase 6 wires the push).
93
93
  *
94
94
  * @param {string} projectDir - Project root with the Dockerfile.
95
- * @param {{projectName: string, timestamp?: string, rebuild?: boolean, tagPrefix?: string}} options
95
+ * @param {{projectName: string, timestamp?: string, rebuild?: boolean, tagPrefix?: string, buildArgs?: Record<string,string>}} options
96
96
  * @returns {{tag: string, gitSha: string, isDirty: boolean}}
97
97
  */
98
98
  export function buildLocalImage(projectDir, options) {
99
- const { projectName, rebuild = false, tagPrefix } = options;
99
+ const { projectName, rebuild = false, tagPrefix, buildArgs = {} } = options;
100
100
  if (!projectName) {
101
101
  throw new Error('buildLocalImage: projectName is required');
102
102
  }
@@ -112,6 +112,14 @@ export function buildLocalImage(projectDir, options) {
112
112
 
113
113
  const args = ['build'];
114
114
  if (rebuild) args.push('--no-cache');
115
+ // Vite inlines import.meta.env.VITE_* at build time, so the browser bundle
116
+ // bakes whatever these ARGs resolve to. Without passing them the bundle ships
117
+ // empty VITE_SUPABASE_* and crashes at page load with "Missing Supabase
118
+ // environment variables" — k8s shipped exactly this because buildAppImage
119
+ // passed no build args (compose plumbs them via collectComposeBuildArgs).
120
+ for (const [k, v] of Object.entries(buildArgs)) {
121
+ if (v !== undefined && v !== null && v !== '') args.push('--build-arg', `${k}=${v}`);
122
+ }
115
123
  args.push('-t', tag, projectDir);
116
124
 
117
125
  execFileSync('docker', args, { stdio: 'inherit' });
@@ -556,9 +556,25 @@ export function loadEnvLocal(envPath) {
556
556
  * @param {boolean} [rebuild]
557
557
  * @returns {Promise<{tag: string, gitSha: string, isDirty: boolean}>}
558
558
  */
559
- export async function buildAppImage(projectDir, projectName, rebuild = false) {
559
+ export async function buildAppImage(projectDir, projectName, rebuild = false, domain = undefined) {
560
560
  const { buildLocalImage } = await import('../image.js');
561
- return buildLocalImage(projectDir, { projectName, rebuild, tagPrefix: '10.0.1.1:5000' });
561
+ // App image: collect VITE_* build args (incl. VITE_SUPABASE_URL rewritten to
562
+ // https://api.<domain>) so the browser bundle ships with real config instead
563
+ // of empty strings. buildLocalImage previously passed NO build args, so the
564
+ // k8s SPA crashed on load with "Missing Supabase environment variables" —
565
+ // compose plumbs these via collectComposeBuildArgs, k8s did not. The backup
566
+ // image build (no frontend) calls this without a domain → no VITE args.
567
+ let buildArgs = {};
568
+ if (domain) {
569
+ const { collectComposeBuildArgs } = await import('../compose/build-args.js');
570
+ buildArgs = collectComposeBuildArgs(projectDir, { projectName, domain });
571
+ }
572
+ return buildLocalImage(projectDir, {
573
+ projectName,
574
+ rebuild,
575
+ tagPrefix: '10.0.1.1:5000',
576
+ buildArgs,
577
+ });
562
578
  }
563
579
 
564
580
  /**
@@ -2436,11 +2452,14 @@ export async function deployK3s(options) {
2436
2452
  }
2437
2453
 
2438
2454
  // 6. Build the app image locally
2439
- const buildInputs = { projectName: options.projectName };
2455
+ // domain is part of the cache key + passed to the build: it's baked into the
2456
+ // browser bundle (VITE_SUPABASE_URL=https://api.<domain>), so a domain change
2457
+ // must force a rebuild rather than serving a stale-domain bundle.
2458
+ const buildInputs = { projectName: options.projectName, domain: options.domain };
2440
2459
  if (!state.shouldSkip('k3s-build', buildInputs)) {
2441
2460
  state.startStep('k3s-build', buildInputs);
2442
2461
  s.start('Building app image (10.0.1.1:5000/...)');
2443
- const built = await buildAppImage(projectDir, options.projectName);
2462
+ const built = await buildAppImage(projectDir, options.projectName, false, options.domain);
2444
2463
  state.completeStep('k3s-build', {
2445
2464
  tag: built.tag,
2446
2465
  gitSha: built.gitSha,