@wpmoo/toolkit 0.9.9 → 0.9.11

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/README.md CHANGED
@@ -144,6 +144,10 @@ Every cockpit action maps to a direct command, so the same workflow can be used
144
144
  ./moo restore-snapshot --dry-run before-update devel
145
145
  ```
146
146
 
147
+ In `WPMOO_ENV=prod`, `install`, `update`, and `test` require `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
148
+ `resetdb` and real `restore-snapshot` require `WPMOO_ALLOW_DESTRUCTIVE=1` in `stage` and `prod`.
149
+ `restore-snapshot --dry-run` remains allowed for preview.
150
+
147
151
  Module source actions also have direct commands. Default is `private`; pass `--source-type oca` or `--source-type external` for non-private source repositories:
148
152
 
149
153
  ```bash
@@ -166,9 +170,11 @@ npx @wpmoo/toolkit doctor --json --postgres
166
170
 
167
171
  JSON output is optional; human-readable output remains the default.
168
172
  `doctor --postgres` adds read-only PostgreSQL health and performance diagnostics
169
- such as database size, active connections, slow-query readiness, extension
170
- visibility, and settings.
173
+ such as database size, sessions currently running queries with
174
+ `pg_stat_activity.state = 'active'`, connection utilization against
175
+ `max_connections`, slow-query readiness, extension visibility, and settings.
171
176
  `doctor --json --postgres` includes a structured `postgres` object for automation.
177
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
172
178
 
173
179
  ## Documentation
174
180
 
@@ -155,9 +155,15 @@ function isDestructiveCommand(command, args) {
155
155
  return true;
156
156
  return command === 'restore-snapshot' && args[0] !== '--dry-run';
157
157
  }
158
+ function isProductionLifecycleCommand(command) {
159
+ return command === 'install' || command === 'update' || command === 'test';
160
+ }
158
161
  function destructiveCommandError(command, envName) {
159
162
  return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
160
163
  }
164
+ function productionLifecycleCommandError(command) {
165
+ return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
166
+ }
161
167
  async function assertDestructiveCommandAllowed(command, args, cwd) {
162
168
  if (!isDestructiveCommand(command, args)) {
163
169
  return;
@@ -172,6 +178,20 @@ async function assertDestructiveCommandAllowed(command, args, cwd) {
172
178
  throw new Error(destructiveCommandError(command, envName));
173
179
  }
174
180
  }
181
+ async function assertProductionLifecycleCommandAllowed(command, cwd) {
182
+ if (!isProductionLifecycleCommand(command)) {
183
+ return;
184
+ }
185
+ const env = await readEnvFile(cwd);
186
+ const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
187
+ if (envName !== 'prod') {
188
+ return;
189
+ }
190
+ const allowProdLifecycle = process.env.WPMOO_ALLOW_PROD_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_PROD_LIFECYCLE')?.trim();
191
+ if (allowProdLifecycle !== '1') {
192
+ throw new Error(productionLifecycleCommandError(command));
193
+ }
194
+ }
175
195
  async function assertEnvironmentRoot(cwd) {
176
196
  try {
177
197
  await access(join(cwd, markerPath));
@@ -194,6 +214,7 @@ export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
194
214
  await assertEnvironmentRoot(cwd);
195
215
  const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
196
216
  const args = scriptArgs(command, argv);
217
+ await assertProductionLifecycleCommandAllowed(command, cwd);
197
218
  await assertDestructiveCommandAllowed(command, args, cwd);
198
219
  return {
199
220
  cwd,
package/dist/doctor.js CHANGED
@@ -55,6 +55,16 @@ WITH metrics(metric, value) AS (
55
55
  SELECT 'active_connections', count(*)::text
56
56
  FROM pg_stat_activity
57
57
  WHERE datname IS NOT NULL
58
+ AND state = 'active'
59
+ UNION ALL
60
+ SELECT 'connection_count', count(*)::text
61
+ FROM pg_stat_activity
62
+ WHERE datname IS NOT NULL
63
+ UNION ALL
64
+ SELECT 'max_connections', COALESCE(
65
+ (SELECT setting FROM pg_settings WHERE name = 'max_connections'),
66
+ 'unavailable'
67
+ )
58
68
  UNION ALL
59
69
  SELECT 'total_database_size_bytes', COALESCE(sum(pg_database_size(datname)), 0)::text
60
70
  FROM pg_database
@@ -82,16 +92,20 @@ FROM metrics
82
92
  ORDER BY CASE metric
83
93
  WHEN 'database_count' THEN 1
84
94
  WHEN 'active_connections' THEN 2
85
- WHEN 'total_database_size_bytes' THEN 3
86
- WHEN 'slow_query_logging' THEN 4
87
- WHEN 'pg_stat_statements' THEN 5
88
- WHEN 'shared_buffers' THEN 6
95
+ WHEN 'connection_count' THEN 3
96
+ WHEN 'max_connections' THEN 4
97
+ WHEN 'total_database_size_bytes' THEN 5
98
+ WHEN 'slow_query_logging' THEN 6
99
+ WHEN 'pg_stat_statements' THEN 7
100
+ WHEN 'shared_buffers' THEN 8
89
101
  ELSE 99
90
102
  END;
91
103
  `.trim();
92
104
  const postgresDiagnosticKeys = [
93
105
  'database_count',
94
106
  'active_connections',
107
+ 'connection_count',
108
+ 'max_connections',
95
109
  'total_database_size_bytes',
96
110
  'slow_query_logging',
97
111
  'pg_stat_statements',
@@ -126,27 +140,79 @@ function parsePostgresDiagnostics(output) {
126
140
  return diagnostics;
127
141
  }
128
142
  function renderPostgresDiagnostics(diagnostics) {
143
+ const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
129
144
  const parts = postgresDiagnosticKeys.flatMap((key) => {
130
145
  const value = diagnostics[key];
131
- return value ? [`${key}=${value}`] : [];
146
+ const rendered = value ? [`${key}=${value}`] : [];
147
+ if (key === 'max_connections' && connectionUtilizationPct !== undefined) {
148
+ rendered.push(`connection_utilization_pct=${connectionUtilizationPct}`);
149
+ }
150
+ return rendered;
132
151
  });
133
152
  return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
134
153
  }
154
+ function missingPostgresDiagnosticKeys(diagnostics) {
155
+ return postgresDiagnosticKeys.filter((key) => !diagnostics[key]);
156
+ }
157
+ function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
158
+ return Object.keys(diagnostics).length === 0
159
+ ? 'no diagnostic rows returned'
160
+ : `incomplete diagnostic rows: missing ${missingKeys.join(', ')}`;
161
+ }
135
162
  function integerDiagnostic(value) {
136
163
  if (!value || !/^\d+$/u.test(value)) {
137
164
  return undefined;
138
165
  }
139
166
  return Number.parseInt(value, 10);
140
167
  }
168
+ function malformedPostgresDiagnosticKeys(diagnostics) {
169
+ const numericKeys = [
170
+ 'database_count',
171
+ 'active_connections',
172
+ 'connection_count',
173
+ 'max_connections',
174
+ 'total_database_size_bytes',
175
+ ];
176
+ return numericKeys.filter((key) => diagnostics[key] !== undefined && integerDiagnostic(diagnostics[key]) === undefined);
177
+ }
178
+ function postgresConnectionUtilizationPct(diagnostics) {
179
+ const connectionCount = integerDiagnostic(diagnostics.connection_count);
180
+ const maxConnections = integerDiagnostic(diagnostics.max_connections);
181
+ if (connectionCount === undefined || maxConnections === undefined || maxConnections <= 0) {
182
+ return undefined;
183
+ }
184
+ return Math.round((connectionCount / maxConnections) * 100);
185
+ }
186
+ function postgresConnectionUtilizationWarning(diagnostics) {
187
+ const connectionCount = integerDiagnostic(diagnostics.connection_count);
188
+ const maxConnections = integerDiagnostic(diagnostics.max_connections);
189
+ const utilizationPct = postgresConnectionUtilizationPct(diagnostics);
190
+ if (connectionCount === undefined ||
191
+ maxConnections === undefined ||
192
+ utilizationPct === undefined ||
193
+ utilizationPct < 80) {
194
+ return undefined;
195
+ }
196
+ return `PostgreSQL connection utilization is high: ${utilizationPct}% of max_connections used (${connectionCount}/${maxConnections}).`;
197
+ }
141
198
  function structuredPostgresDiagnostics(diagnostics) {
142
199
  const structured = {};
143
200
  const databaseCount = integerDiagnostic(diagnostics.database_count);
144
201
  const activeConnections = integerDiagnostic(diagnostics.active_connections);
202
+ const connectionCount = integerDiagnostic(diagnostics.connection_count);
203
+ const maxConnections = integerDiagnostic(diagnostics.max_connections);
204
+ const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
145
205
  const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
146
206
  if (databaseCount !== undefined)
147
207
  structured.databaseCount = databaseCount;
148
208
  if (activeConnections !== undefined)
149
209
  structured.activeConnections = activeConnections;
210
+ if (connectionCount !== undefined)
211
+ structured.connectionCount = connectionCount;
212
+ if (maxConnections !== undefined)
213
+ structured.maxConnections = maxConnections;
214
+ if (connectionUtilizationPct !== undefined)
215
+ structured.connectionUtilizationPct = connectionUtilizationPct;
150
216
  if (totalDatabaseSizeBytes !== undefined)
151
217
  structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
152
218
  if (diagnostics.slow_query_logging)
@@ -555,22 +621,32 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
555
621
  if (actualOptions.postgres) {
556
622
  try {
557
623
  const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner);
558
- const renderedPostgresDiagnostics = renderPostgresDiagnostics(postgresDiagnostics);
559
- if (renderedPostgresDiagnostics) {
560
- checks.push(renderedPostgresDiagnostics);
624
+ const missingKeys = missingPostgresDiagnosticKeys(postgresDiagnostics);
625
+ const malformedKeys = malformedPostgresDiagnosticKeys(postgresDiagnostics);
626
+ if (missingKeys.length === 0 && malformedKeys.length === 0) {
627
+ const renderedPostgresDiagnostics = renderPostgresDiagnostics(postgresDiagnostics);
628
+ if (renderedPostgresDiagnostics) {
629
+ checks.push(renderedPostgresDiagnostics);
630
+ }
561
631
  report.postgres = {
562
632
  requested: true,
563
633
  available: true,
564
634
  diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
565
635
  };
636
+ const connectionUtilizationWarning = postgresConnectionUtilizationWarning(postgresDiagnostics);
637
+ if (connectionUtilizationWarning) {
638
+ warnings.push(connectionUtilizationWarning);
639
+ }
566
640
  }
567
641
  else {
568
- const warning = 'no diagnostic rows returned';
642
+ const warning = malformedKeys.length > 0
643
+ ? `malformed diagnostic values: ${malformedKeys.join(', ')}`
644
+ : unavailablePostgresDiagnosticsWarning(postgresDiagnostics, missingKeys);
569
645
  warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
570
646
  report.postgres = {
571
647
  requested: true,
572
648
  available: false,
573
- diagnostics: {},
649
+ diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
574
650
  warning,
575
651
  };
576
652
  }
@@ -43,6 +43,8 @@ export function renderComposeEnvExample(options) {
43
43
  '# Required only when intentionally running destructive database actions',
44
44
  '# such as resetdb or restore-snapshot with WPMOO_ENV=stage or WPMOO_ENV=prod.',
45
45
  '# WPMOO_ALLOW_DESTRUCTIVE=1',
46
+ '# Required only when intentionally running install/update/test in WPMOO_ENV=prod.',
47
+ '# WPMOO_ALLOW_PROD_LIFECYCLE=1',
46
48
  '',
47
49
  ].join('\n');
48
50
  }
package/dist/help.js CHANGED
@@ -85,6 +85,11 @@ Daily actions:
85
85
  Generated environments also include ./moo for local compose commands such as ./moo start.
86
86
  Use ./moo or npx @wpmoo/toolkit with the same daily action arguments.
87
87
 
88
+ Production command guards:
89
+ In WPMOO_ENV=prod, install/update/test require WPMOO_ALLOW_PROD_LIFECYCLE=1.
90
+ resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage/prod.
91
+ restore-snapshot --dry-run remains allowed for preview.
92
+
88
93
  Cockpit:
89
94
  Run npx @wpmoo/toolkit inside a generated environment to open the cockpit.
90
95
  Use Command palette / to search slash commands across services, modules, database,
@@ -104,7 +109,10 @@ Status and doctor:
104
109
  doctor: deeper health check. May check Docker CLI access and GitHub workflows.
105
110
  doctor --fix: applies safe file-level repairs. Runs doctor again after fixes.
106
111
  doctor --postgres: adds read-only PostgreSQL diagnostics such as database size,
107
- active connections, slow-query readiness, extension visibility, and settings.
112
+ sessions currently running queries with pg_stat_activity.state = 'active',
113
+ connection utilization against max_connections, slow-query readiness,
114
+ extension visibility, and settings.
115
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
108
116
 
109
117
  Task recipes:
110
118
  Create environment:
@@ -148,6 +156,7 @@ Machine-readable JSON output:
148
156
  npx @wpmoo/toolkit source sync --json
149
157
  npx @wpmoo/toolkit doctor --json
150
158
  doctor --json --postgres includes a structured postgres object for automation.
159
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
151
160
 
152
161
  Example:
153
162
  npx @wpmoo/toolkit create \\
package/dist/templates.js CHANGED
@@ -208,6 +208,10 @@ resources/odoo/entrypoint.sh
208
208
 
209
209
  Development uses compose.yaml plus compose/dev.yaml by default.
210
210
  Set WPMOO_ENV=stage or WPMOO_ENV=prod only after providing production-grade secrets and volumes.
211
+ In WPMOO_ENV=prod, module lifecycle commands such as install, update, and test
212
+ require WPMOO_ALLOW_PROD_LIFECYCLE=1. Destructive database commands such as
213
+ resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage
214
+ and prod. restore-snapshot --dry-run remains available for preview.
211
215
 
212
216
  If copied from the standalone resource, additional compose notes are in
213
217
  \`docs/compose.md\`.
@@ -568,6 +572,23 @@ require_destructive_allowed() {
568
572
  fi
569
573
  }
570
574
 
575
+ allow_prod_lifecycle() {
576
+ local value="\${WPMOO_ALLOW_PROD_LIFECYCLE:-$(env_file_value WPMOO_ALLOW_PROD_LIFECYCLE)}"
577
+ [[ "$value" == "1" ]]
578
+ }
579
+
580
+ require_prod_lifecycle_allowed() {
581
+ local command="$1"
582
+ local env_name
583
+ env_name="$(selected_env)"
584
+ if [[ "$env_name" == "prod" ]]; then
585
+ if ! allow_prod_lifecycle; then
586
+ echo "Refusing production lifecycle command '$command' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally." >&2
587
+ exit 1
588
+ fi
589
+ fi
590
+ }
591
+
571
592
  run_script() {
572
593
  local script="$1"
573
594
  shift
@@ -613,16 +634,19 @@ case "$command" in
613
634
  "install")
614
635
  shift
615
636
  require_module_args "$command" "$@"
637
+ require_prod_lifecycle_allowed "$command"
616
638
  run_script ./scripts/install.sh "$@"
617
639
  ;;
618
640
  "update")
619
641
  shift
620
642
  require_module_args "$command" "$@"
643
+ require_prod_lifecycle_allowed "$command"
621
644
  run_script ./scripts/update.sh "$@"
622
645
  ;;
623
646
  "test")
624
647
  shift
625
648
  validate_test_args "$@"
649
+ require_prod_lifecycle_allowed "$command"
626
650
  run_script ./scripts/test.sh "$@"
627
651
  ;;
628
652
  "resetdb")
@@ -23,7 +23,7 @@ not validate staging or production deployments.
23
23
  | Doctor checks | Metadata, compose files, scripts, source repo paths, and local tooling checks behave as expected. | `npx @wpmoo/toolkit doctor` or `./moo doctor` |
24
24
  | Doctor safe fixes | Safe file-level fixes are applied only with `--fix`, then doctor runs again and reports any remaining manual issues. | `npx @wpmoo/toolkit doctor --fix` |
25
25
  | Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths and can normalize safe targets with `--fix`. | `npx @wpmoo/toolkit doctor`, `npx @wpmoo/toolkit doctor --fix` |
26
- | PostgreSQL diagnostics | Optional read-only database health/performance diagnostics report database count, active connections, total database size, slow-query readiness, extension visibility, and selected settings without failing doctor when the database is unavailable. | `npx @wpmoo/toolkit doctor --postgres`, `npx @wpmoo/toolkit doctor --json --postgres` |
26
+ | PostgreSQL diagnostics | Optional read-only database health/performance diagnostics report database count, sessions currently running queries with `pg_stat_activity.state = 'active'`, total database size, slow-query readiness, extension visibility, and selected settings without failing doctor when the database is unavailable. | `npx @wpmoo/toolkit doctor --postgres`, `npx @wpmoo/toolkit doctor --json --postgres` |
27
27
  | Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/toolkit add-repo ...`, `npx @wpmoo/toolkit remove-repo ...` |
28
28
  | Source manifest sync | Source repo metadata, `.gitmodules`, and `odoo/custom/manifests/sources.yaml` stay aligned. | `npx @wpmoo/toolkit source list`, `npx @wpmoo/toolkit source sync` |
29
29
  | Module add/remove | Module skeleton files include manifest, model, access CSV, explicit view XML, action/menu XML, post-install test scaffold, and selected source repo registration. Existing scaffold files are not overwritten. | `npx @wpmoo/toolkit add-module ...`, `npx @wpmoo/toolkit remove-module ...` |
@@ -53,6 +53,12 @@ scripts unless `.env` or the process environment explicitly sets
53
53
  `WPMOO_ALLOW_DESTRUCTIVE=1`. `restore-snapshot --dry-run` remains allowed for
54
54
  safe preview.
55
55
 
56
+ When `WPMOO_ENV=prod`, WPMoo also refuses module lifecycle commands that mutate
57
+ or exercise the Odoo database (`install`, `update`, and `test`) unless `.env` or
58
+ the process environment explicitly sets `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
59
+ Staging keeps these commands available for release rehearsal while still
60
+ enforcing the destructive database guard above.
61
+
56
62
  For PostgreSQL 18 environments (including `POSTGRES_IMAGE=postgres:18`), ensure db
57
63
  volume and tmpfs mount targets use `/var/lib/postgresql` directly:
58
64
 
@@ -70,12 +76,16 @@ is involved, use PostgreSQL upgrade tooling first.
70
76
 
71
77
  Use `doctor --postgres` when the database container is running and you want
72
78
  read-only PostgreSQL diagnostics. The check uses fixed diagnostic queries for
73
- database count, active connections, aggregate database size, slow-query logging
74
- readiness, `pg_stat_statements` availability, and `shared_buffers`. If the
75
- database is unavailable, doctor reports a warning instead of failing the whole
76
- environment check.
79
+ database count, sessions currently running queries where
80
+ `pg_stat_activity.state` is `active`, aggregate database size, slow-query
81
+ logging readiness,
82
+ `pg_stat_statements` availability, and `shared_buffers`. If the database is
83
+ unavailable, doctor reports a warning instead of failing the whole environment
84
+ check.
77
85
  JSON output preserves `checks` and `warnings` while adding a structured
78
86
  `postgres` object when `--postgres` is requested.
87
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable
88
+ diagnostics.
79
89
 
80
90
  ## Safe reset policy
81
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {