@leverege/build-tools 2.101.2 → 2.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/launch.json +10 -0
- package/README.md +22 -9
- package/package.json +10 -13
- package/src/Utils.mjs +2 -0
- package/src/chart-to-registry.mjs +12 -1
- package/src/circle-orb-it/{circle-orb.yml → circle-npm.yml} +6 -3
- package/src/circle-orb-it/circle-orb-it.mjs +16 -11
- package/src/circle-orb-it/circle-yarn.yml +25 -0
- package/src/dashboard/DashboardManager.mjs +192 -17
- package/src/dashboard/dashboard.mjs +29 -4
- package/src/dashboard/json/{standard/noc-rows-12.json → devops/devops-noc.json} +801 -61
- package/src/dashboard/json/{standard/pvc-all-usage.json → devops/pvc-usage.json} +2 -2
- package/src/dashboard/json/leverege/leverege-noc.json +2364 -0
- package/src/dashboard/json/{standard/redis.json → leverege/leverege-rule-engine.json} +695 -646
- package/src/dashboard/json/leverege/leverege-services.json +4757 -0
- package/src/dashboard/json/recovr/recovr-locators.json +1227 -0
- package/src/dashboard/json/recovr/recovr-noc.json +1949 -0
- package/src/dashboard/json/recovr/recovr-position-srvr.json +339 -0
- package/src/dashboard/json/recovr/recovr-server.json +1127 -0
- package/src/dashboard/json/siren/siren-services.json +2066 -0
- package/src/dashboard/json/{standard/cloudnative-pg-20417-rev4.json → system/cloudnative-pg.json} +241 -243
- package/src/dashboard/json/{standard → system}/elasticsearch8.json +123 -72
- package/src/dashboard/json/system/stackdriver.json +1189 -0
- package/src/dashboard/json/{standard/traefik-op.json → system/traefik.json} +44 -56
- package/src/dashboard/json/{standard → system}/valkey.json +91 -86
- package/src/dashboard/json/{standard → tpi}/gpu-autoscaling-dashboard-ui-import.json +1 -1
- package/src/dashboard/json/tpi/leverege-noc.json +3624 -0
- package/src/helm-charts/prom-operator/helmup.plugin +1 -1
- package/src/helm-charts/prom-operator/prometheus-stack.yaml.ovh +15 -0
- package/src/helm-charts/prom-operator/rules/gke-maintenance-rules.yaml +19 -0
- package/src/helm-charts/prom-operator/stackdriver-exporter.yaml.ovh +2 -1
- package/src/helm-charts/valkey/valkey-local.yaml +1 -0
- package/src/helmup.sh +24 -1
- package/src/init-my-chart/Chartwright.mjs +1 -1
- package/src/init-my-chart/leaf-chart-templates/templates/extraobjects.yaml +1 -0
- package/src/overwhelm.mjs +5 -5
- package/src/push-my-chart.mjs +13 -4
- package/src/repo-panopticon.mjs +415 -0
- package/src/service-man/config/secrets/npmrc.json +1 -0
- package/src/service-man/config/secrets/tsdb-dense-password.json +13 -0
- package/src/dashboard/json/standard/stackdriver.json +0 -624
- package/src/pkgck.sh +0 -158
- /package/src/dashboard/json/{standard → devops}/k8s-api-latency.json +0 -0
|
@@ -8,7 +8,7 @@ showInstalling "The Prometheus Operator (kube-prometheus-stack)"
|
|
|
8
8
|
|
|
9
9
|
# https://artifacthub.io/packages/helm/prometheus-community/kube-prometheus-stack
|
|
10
10
|
OCI_CHART="oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack"
|
|
11
|
-
[ -z "$PROMETHEUS_STACK_CHART_VERSION" ] && PROMETHEUS_STACK_CHART_VERSION="
|
|
11
|
+
[ -z "$PROMETHEUS_STACK_CHART_VERSION" ] && PROMETHEUS_STACK_CHART_VERSION="85.0.2"
|
|
12
12
|
helm upgrade $NS --install prometheus-stack $OCI_CHART \
|
|
13
13
|
--values prom-operator/prometheus-stack.yaml \
|
|
14
14
|
--version $PROMETHEUS_STACK_CHART_VERSION $HELM_WHAT
|
|
@@ -143,9 +143,15 @@ grafana:
|
|
|
143
143
|
sidecar:
|
|
144
144
|
dashboards:
|
|
145
145
|
enabled: true
|
|
146
|
+
folderAnnotation: grafana_folder
|
|
147
|
+
provider:
|
|
148
|
+
foldersFromFilesStructure: true
|
|
146
149
|
# Credentials are injected from the grafana-google-oauth k8s secret, which is
|
|
147
150
|
# managed by the ExternalSecret in helmup.plugin once ESO is deployed.
|
|
148
151
|
# On first install, the secret is a placeholder created by helmup.bootstrap.
|
|
152
|
+
grafana.ini:
|
|
153
|
+
feature_toggles:
|
|
154
|
+
ssoSettingsApi: false
|
|
149
155
|
envFromSecret: grafana-google-oauth
|
|
150
156
|
env:
|
|
151
157
|
GF_SERVER_ROOT_URL: https://${PROJECT_NAME}-monitoring.${HOST}.com
|
|
@@ -310,6 +316,15 @@ alertmanager:
|
|
|
310
316
|
- namespace
|
|
311
317
|
- alertname
|
|
312
318
|
|
|
319
|
+
# GKE node upgrade/maintenance suppresses expected churn alerts.
|
|
320
|
+
# PVC, disk pressure, and other infrastructure alerts are intentionally
|
|
321
|
+
# excluded so genuine storage/capacity problems are never silenced.
|
|
322
|
+
- source_matchers:
|
|
323
|
+
- alertname="GKEMaintenanceInProgress"
|
|
324
|
+
- maintenance="true"
|
|
325
|
+
target_matchers:
|
|
326
|
+
- alertname=~"KubernetesNodeNotReady|KubernetesPodNotHealthy|KubernetesPodCrashLooping|frequent_app_restarts"
|
|
327
|
+
|
|
313
328
|
route:
|
|
314
329
|
receiver: slack # default for everything
|
|
315
330
|
group_by: ["alertname", "namespace"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
apiVersion: monitoring.coreos.com/v1
|
|
2
|
+
kind: PrometheusRule
|
|
3
|
+
metadata:
|
|
4
|
+
name: gke-maintenance-rules
|
|
5
|
+
namespace: prometheus
|
|
6
|
+
|
|
7
|
+
spec:
|
|
8
|
+
groups:
|
|
9
|
+
- name: GKE Maintenance
|
|
10
|
+
rules:
|
|
11
|
+
- alert: GKEMaintenanceInProgress
|
|
12
|
+
expr: sum(kube_node_spec_unschedulable) > 0
|
|
13
|
+
for: 3m
|
|
14
|
+
labels:
|
|
15
|
+
severity: info
|
|
16
|
+
maintenance: "true"
|
|
17
|
+
annotations:
|
|
18
|
+
summary: "GKE node maintenance in progress"
|
|
19
|
+
description: "{{ $value }} node(s) are currently unschedulable — GKE upgrade or cordon in progress"
|
|
@@ -19,7 +19,8 @@ stackdriver:
|
|
|
19
19
|
kubernetes.io/container/accelerator/duty_cycle,
|
|
20
20
|
kubernetes.io/container/accelerator/memory_total,
|
|
21
21
|
kubernetes.io/container/accelerator/memory_used,
|
|
22
|
-
|
|
22
|
+
pubsub.googleapis.com/subscription/backlog_bytes,
|
|
23
|
+
pubsub.googleapis.com/subscription/dead_letter_message_count,
|
|
23
24
|
pubsub.googleapis.com/subscription/num_undelivered_messages,
|
|
24
25
|
pubsub.googleapis.com/subscription/oldest_unacked_message_age,
|
|
25
26
|
pubsub.googleapis.com/subscription/pull_ack_request_count,
|
package/src/helmup.sh
CHANGED
|
@@ -20,6 +20,7 @@ function giveHelp() {
|
|
|
20
20
|
`color g "--k8s-match"` Only proceed if k8s context is correctly set
|
|
21
21
|
`color g "--dry-run"` Show what helm would do without applying changes
|
|
22
22
|
`color g "--debug"` Enable verbose debug output
|
|
23
|
+
`color g "--mutate"` Forces a deployment mutation
|
|
23
24
|
`color g "--help"` Show this help message
|
|
24
25
|
|
|
25
26
|
Invocation Modes:
|
|
@@ -48,6 +49,9 @@ for arg in "$@"; do
|
|
|
48
49
|
--k8s-match)
|
|
49
50
|
export OVERWHELM_ARGS="--matchk8s"
|
|
50
51
|
;;
|
|
52
|
+
--mutate)
|
|
53
|
+
MUTATE_DEPLOYMENT=1
|
|
54
|
+
;;
|
|
51
55
|
--help|-h)
|
|
52
56
|
giveHelp && exit 0
|
|
53
57
|
;;
|
|
@@ -622,6 +626,25 @@ function doHelmup() {
|
|
|
622
626
|
local from=
|
|
623
627
|
[ ! -z "$FROM_VERSIONS" ] && from="`color y \"<= from Versions.json\"`"
|
|
624
628
|
printf "$doing $from\n"
|
|
629
|
+
if [[ $MUTATE_DEPLOYMENT -eq 1 ]];
|
|
630
|
+
then
|
|
631
|
+
cat<<MUTATION_MSG
|
|
632
|
+
|
|
633
|
+
`color y "WARNING: About to delete the $SERVICE k8s deployment object!
|
|
634
|
+
This will cause brief downtime while helm recreates it. The service
|
|
635
|
+
and any LoadBalancer/static IP will be unaffected."`
|
|
636
|
+
|
|
637
|
+
MUTATION_MSG
|
|
638
|
+
yesToContinue "to delete the $SERVICE deployment and proceed with upgrade"
|
|
639
|
+
kubectl delete deployments.apps $SERVICE
|
|
640
|
+
cat<<DELETE_WAIT
|
|
641
|
+
|
|
642
|
+
`color y " Waiting for $SERVICE deployment to be fully removed..."`
|
|
643
|
+
|
|
644
|
+
DELETE_WAIT
|
|
645
|
+
kubectl wait --for=delete deployment/$SERVICE --timeout=120s
|
|
646
|
+
fi
|
|
647
|
+
|
|
625
648
|
# plugins pass HELMUP as a single quoted string - split it into args without using eval
|
|
626
649
|
[[ $# -eq 1 ]] && set -- $1
|
|
627
650
|
"$@" --set "helmup.culprit=$culprit" --set "helmup.deployT=$deployT"
|
|
@@ -811,7 +834,7 @@ REBOOTSTRAP
|
|
|
811
834
|
}
|
|
812
835
|
|
|
813
836
|
# ---------- helmup MAIN ----------
|
|
814
|
-
helm repo update &>/dev/null & # see comments in the doInstall function for rationale
|
|
837
|
+
helm repo update leverege &>/dev/null & # see comments in the doInstall function for rationale
|
|
815
838
|
|
|
816
839
|
overwhelm $OVERWHELM_ARGS
|
|
817
840
|
[ $? -ne 0 ] && printf "\n\n***Aborting helmup...\n\n" && exit 1
|
|
@@ -13,7 +13,7 @@ const hbsContext = {
|
|
|
13
13
|
hbsChartName : '',
|
|
14
14
|
hbsChartVersion : '1.0.0',
|
|
15
15
|
hbsChartAppVersion : 'v1.0.0',
|
|
16
|
-
hbsArtifactRegistry : 'us-docker.pkg.dev/leverege-registry/
|
|
16
|
+
hbsArtifactRegistry : 'us-docker.pkg.dev/leverege-registry/FOLDER/images',
|
|
17
17
|
hbsPartOf : '',
|
|
18
18
|
hbsServicePreemptible : true,
|
|
19
19
|
hbsRedisEnabled : true,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{ include "leverege.extraObjects" . }}
|
package/src/overwhelm.mjs
CHANGED
|
@@ -250,14 +250,14 @@ const buildYaml = ( yamls, replacements ) => {
|
|
|
250
250
|
const values = buildYaml( yams, over )
|
|
251
251
|
|
|
252
252
|
// Check to see if someone is requesting a value from the yaml.
|
|
253
|
+
// Uses the fully merged+resolved `over` object so context-specific keys are found.
|
|
253
254
|
if ( args.key ) {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
debug( { keys }, '<==Replacements' )
|
|
255
|
+
const val = over[args.key] ?? ''
|
|
256
|
+
debug( { over }, '<==Replacements' )
|
|
257
257
|
if ( args.export ) {
|
|
258
|
-
log( `export ${args.key}=${
|
|
258
|
+
log( `export ${args.key}=${val}` )
|
|
259
259
|
} else {
|
|
260
|
-
log(
|
|
260
|
+
log( val )
|
|
261
261
|
}
|
|
262
262
|
if ( !args.continue ) process.exit()
|
|
263
263
|
}
|
package/src/push-my-chart.mjs
CHANGED
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
errorExit,
|
|
10
10
|
log,
|
|
11
11
|
parseHelmChart,
|
|
12
|
-
|
|
12
|
+
proceed,
|
|
13
|
+
shellCmd,
|
|
14
|
+
warning,
|
|
13
15
|
} from './Utils.mjs'
|
|
14
16
|
|
|
15
17
|
const commandLineOptions = [
|
|
@@ -56,11 +58,18 @@ if ( args.help ) {
|
|
|
56
58
|
const {
|
|
57
59
|
chartName,
|
|
58
60
|
chartVersion,
|
|
61
|
+
chartAppVersion,
|
|
59
62
|
depsOutOfSync,
|
|
60
63
|
} = await parseHelmChart( args.chart )
|
|
61
64
|
|
|
62
|
-
log( chalk.green( `Chart
|
|
63
|
-
log( chalk.green( `Version
|
|
65
|
+
log( chalk.green( `Chart => ${chalk.bold.yellow( chartName )}` ) )
|
|
66
|
+
log( chalk.green( `Version => ${chalk.bold.yellow( chartVersion )}` ) )
|
|
67
|
+
log( chalk.green( `AppVersion => ${chalk.bold.yellow( chartAppVersion )}` ) )
|
|
68
|
+
|
|
69
|
+
if ( !chartAppVersion.startsWith( 'v' ) ) {
|
|
70
|
+
warning( 'Chart.yaml appVersion does not begin with the letter "v"\n' )
|
|
71
|
+
await proceed( 'You sure about this?' )
|
|
72
|
+
}
|
|
64
73
|
|
|
65
74
|
if ( depsOutOfSync ) {
|
|
66
75
|
try {
|
|
@@ -89,7 +98,7 @@ if ( args.noRegistry ) {
|
|
|
89
98
|
|
|
90
99
|
try {
|
|
91
100
|
log( chalk.green( ' ... pushing to the Artifact Registry' ) )
|
|
92
|
-
const registry = await shellCmd( `chart-to-registry --chart ${args.chart}` )
|
|
101
|
+
const registry = await shellCmd( `chart-to-registry --chart ${args.chart} --ignore-appv` )
|
|
93
102
|
debug( { registry }, '<==chart-to-registry' )
|
|
94
103
|
process.exit( museumStatus )
|
|
95
104
|
} catch ( ex ) {
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable security/detect-non-literal-fs-filename, no-multi-spaces, key-spacing */
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { program } from 'commander'
|
|
7
|
+
import chalk from 'chalk'
|
|
8
|
+
import { glob } from 'glob'
|
|
9
|
+
import ora from 'ora'
|
|
10
|
+
import pLimit from 'p-limit'
|
|
11
|
+
import simpleGit from 'simple-git'
|
|
12
|
+
|
|
13
|
+
import { clear, enableDebug, log, parseJsonFile } from './Utils.mjs'
|
|
14
|
+
|
|
15
|
+
// ---------- Symbols ----------
|
|
16
|
+
|
|
17
|
+
const SYM_GOOD = chalk.green( '✔' )
|
|
18
|
+
const SYM_BAD = chalk.red( '✘' )
|
|
19
|
+
|
|
20
|
+
// ---------- Checks ----------
|
|
21
|
+
// Each check: { label: string, fn: ({ dir, pkg, isWorkspace }) => { value: string } }
|
|
22
|
+
// Add new checks here — the table expands automatically.
|
|
23
|
+
|
|
24
|
+
const CHECKS = [
|
|
25
|
+
{
|
|
26
|
+
label : 'cir',
|
|
27
|
+
fn : ( { dir } ) => {
|
|
28
|
+
const cfgPath = path.join( dir, '.circleci', 'config.yml' )
|
|
29
|
+
if ( !fs.existsSync( cfgPath ) ) return { value : SYM_BAD }
|
|
30
|
+
try {
|
|
31
|
+
const content = fs.readFileSync( cfgPath, 'utf8' )
|
|
32
|
+
const match = content.match( /leverege\/circle-nodejs-ci@(\d+)\.\d+\.\d+/ )
|
|
33
|
+
if ( !match || parseInt( match[1], 10 ) === 0 ) return { value : chalk.hex( '#FFA500' )( 'L' ) }
|
|
34
|
+
const major = parseInt( match[1], 10 )
|
|
35
|
+
return { value : major === 1 ? chalk.yellow( '1' ) : chalk.green( `${major}` ) }
|
|
36
|
+
} catch {
|
|
37
|
+
return { value : chalk.hex( '#FFA500' )( 'L' ) }
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
label : 'ESM',
|
|
43
|
+
fn : ( { pkg } ) => {
|
|
44
|
+
const ok = pkg?.type === 'module'
|
|
45
|
+
return { value : ok ? SYM_GOOD : SYM_BAD }
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label : 'har',
|
|
50
|
+
fn : ( { gitRoot } ) => {
|
|
51
|
+
const ok = fs.existsSync( path.join( gitRoot, '.har' ) )
|
|
52
|
+
return { value : ok ? SYM_GOOD : SYM_BAD }
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
label : 'lnt',
|
|
57
|
+
fn : ( { dir, pkg, gitRoot } ) => {
|
|
58
|
+
if ( !pkg?.scripts?.lint ) return { value : SYM_BAD }
|
|
59
|
+
try {
|
|
60
|
+
// Prefer package-local node_modules; fall back to monorepo root (hoisted)
|
|
61
|
+
const local = path.join( dir, 'node_modules', 'eslint', 'package.json' )
|
|
62
|
+
const hoisted = path.join( gitRoot, 'node_modules', 'eslint', 'package.json' )
|
|
63
|
+
const eslintPkg = JSON.parse( fs.readFileSync(
|
|
64
|
+
fs.existsSync( local ) ? local : hoisted, 'utf8'
|
|
65
|
+
) )
|
|
66
|
+
const major = parseInt( eslintPkg.version.split( '.' )[0], 10 )
|
|
67
|
+
return { value : major <= 8 ? chalk.yellow( `${major}` ) : chalk.green( `${major}` ) }
|
|
68
|
+
} catch {
|
|
69
|
+
return { value : chalk.dim( '?' ) }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
label : 'bbl',
|
|
75
|
+
fn : ( { dir, pkg } ) => {
|
|
76
|
+
const configFiles = [ '.babelrc', '.babelrc.js', '.babelrc.json', 'babel.config.js', 'babel.config.json', 'babel.config.cjs' ]
|
|
77
|
+
const hasConfig = configFiles.some( f => fs.existsSync( path.join( dir, f ) ) )
|
|
78
|
+
const allDeps = { ...( pkg?.dependencies ?? {} ), ...( pkg?.devDependencies ?? {} ) }
|
|
79
|
+
const hasDep = Object.keys( allDeps ).some( k => k.includes( 'babel' ) )
|
|
80
|
+
return { value : ( hasConfig || hasDep ) ? SYM_BAD : chalk.dim( '–' ) }
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
// ---------- Helpers ----------
|
|
86
|
+
|
|
87
|
+
const ANSI_RE = /\x1B\[[0-9;]*m/g // eslint-disable-line no-control-regex
|
|
88
|
+
|
|
89
|
+
function visLen( str ) {
|
|
90
|
+
return str.replace( ANSI_RE, '' ).length
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pad( str, width ) {
|
|
94
|
+
return str + ' '.repeat( Math.max( 0, width - visLen( str ) ) )
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function center( str, width ) {
|
|
98
|
+
const gap = Math.max( 0, width - visLen( str ) )
|
|
99
|
+
const left = Math.floor( gap / 2 )
|
|
100
|
+
return ' '.repeat( left ) + str + ' '.repeat( gap - left )
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const NONSEMVER = '!SEMVER'
|
|
104
|
+
|
|
105
|
+
// Strict x.y.z only — pre-release and build metadata are flagged !SEMVER
|
|
106
|
+
function parseVersion( raw ) {
|
|
107
|
+
if ( !raw ) return null
|
|
108
|
+
const v = raw.startsWith( 'v' ) ? raw : `v${raw}`
|
|
109
|
+
return /^v\d+\.\d+\.\d+$/.test( v ) ? v : NONSEMVER
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fmtPkgVer( ver ) {
|
|
113
|
+
if ( !ver ) return chalk.dim( '?' )
|
|
114
|
+
if ( ver === NONSEMVER ) return chalk.yellow( NONSEMVER )
|
|
115
|
+
return chalk.green( ver )
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fmtGitVer( gitVer, pkgVer ) {
|
|
119
|
+
if ( !gitVer ) return chalk.bgRed.white( 'UNTAGGD' )
|
|
120
|
+
if ( gitVer === NONSEMVER ) return chalk.yellow( NONSEMVER )
|
|
121
|
+
if ( gitVer === pkgVer ) return chalk.green( gitVer )
|
|
122
|
+
return chalk.yellow( gitVer )
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fmtScope( pkg ) {
|
|
126
|
+
const name = pkg?.name ?? ''
|
|
127
|
+
const match = name.match( /^(@[^/]+)/ )
|
|
128
|
+
if ( match ) return chalk.green( match[1].slice( 0, 4 ) ) // '@' + 3 chars: @leverege → @lev
|
|
129
|
+
if ( pkg?.private === true ) return chalk.yellow( 'PRVT' )
|
|
130
|
+
return chalk.bgRed.white( 'PUBL' )
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Extracts { repoName, group } from a directory path.
|
|
134
|
+
// For workspace packages (gitRoot !== dir), group is the monorepo's org/name path
|
|
135
|
+
// and repoName is the package's own directory name.
|
|
136
|
+
// For root repos, group is the @xxx path component and repoName is relative to it.
|
|
137
|
+
function repoLabel( dir, gitRoot ) {
|
|
138
|
+
const parts = dir.split( path.sep )
|
|
139
|
+
const atIdx = parts.findIndex( p => /^@\w+$/.test( p ) )
|
|
140
|
+
|
|
141
|
+
if ( gitRoot && gitRoot !== dir ) {
|
|
142
|
+
// Workspace package — group under the monorepo's label
|
|
143
|
+
const rootParts = gitRoot.split( path.sep )
|
|
144
|
+
const rootAtIdx = rootParts.findIndex( p => /^@\w+$/.test( p ) )
|
|
145
|
+
const group = rootAtIdx === -1 ?
|
|
146
|
+
path.basename( gitRoot ) :
|
|
147
|
+
rootParts.slice( rootAtIdx ).join( '/' )
|
|
148
|
+
return { repoName : path.basename( dir ), group }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if ( atIdx === -1 ) return { repoName : path.basename( dir ), group : null }
|
|
152
|
+
const group = parts[atIdx]
|
|
153
|
+
const repoName = parts.slice( atIdx + 1 ).join( '/' )
|
|
154
|
+
return { repoName, group }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------- Per-repo ----------
|
|
158
|
+
|
|
159
|
+
async function checkRepo( dir, { pull } ) {
|
|
160
|
+
if ( !fs.existsSync( path.join( dir, 'package.json' ) ) ) return null
|
|
161
|
+
|
|
162
|
+
let gitRoot
|
|
163
|
+
try {
|
|
164
|
+
gitRoot = ( await simpleGit( dir ).revparse( [ '--show-toplevel' ] ) ).trim()
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const isWorkspace = gitRoot !== dir
|
|
170
|
+
const git = simpleGit( gitRoot )
|
|
171
|
+
const pkg = await parseJsonFile( path.join( dir, 'package.json' ) )
|
|
172
|
+
|
|
173
|
+
// Monorepo roots are represented by their workspace packages — skip the root itself
|
|
174
|
+
if ( !isWorkspace && pkg?.workspaces ) return null
|
|
175
|
+
|
|
176
|
+
const pkgVer = parseVersion( pkg?.version )
|
|
177
|
+
|
|
178
|
+
let syncOk = true
|
|
179
|
+
if ( isWorkspace ) {
|
|
180
|
+
try {
|
|
181
|
+
const relDir = path.relative( gitRoot, dir )
|
|
182
|
+
const status = await git.status( [ '--', relDir ] )
|
|
183
|
+
syncOk = status.files.length === 0
|
|
184
|
+
} catch {
|
|
185
|
+
syncOk = false
|
|
186
|
+
}
|
|
187
|
+
} else if ( pull ) {
|
|
188
|
+
try {
|
|
189
|
+
await git.pull()
|
|
190
|
+
await git.fetch( [ '--tags' ] )
|
|
191
|
+
} catch {
|
|
192
|
+
syncOk = false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let gitVer = null
|
|
197
|
+
if ( isWorkspace ) {
|
|
198
|
+
// Look for workspace-style tags: pkg-name/vX.Y.Z
|
|
199
|
+
const pkgBaseName = ( pkg?.name ?? path.basename( dir ) ).replace( /^@[^/]+\//, '' )
|
|
200
|
+
try {
|
|
201
|
+
const tagOutput = ( await git.raw( [ 'tag', '--list', `${pkgBaseName}/v*` ] ) ).trim()
|
|
202
|
+
if ( tagOutput ) {
|
|
203
|
+
const semverTags = tagOutput.split( '\n' )
|
|
204
|
+
.map( ( t ) => { const m = t.trim().match( /(?:^|\/)v(\d+\.\d+\.\d+)$/ ); return m ? m[1] : null } )
|
|
205
|
+
.filter( Boolean )
|
|
206
|
+
if ( semverTags.length ) {
|
|
207
|
+
semverTags.sort( ( a, b ) => {
|
|
208
|
+
const av = a.split( '.' ).map( Number )
|
|
209
|
+
const bv = b.split( '.' ).map( Number )
|
|
210
|
+
return ( bv[0] - av[0] ) || ( bv[1] - av[1] ) || ( bv[2] - av[2] )
|
|
211
|
+
} )
|
|
212
|
+
gitVer = `v${semverTags[0]}`
|
|
213
|
+
} else {
|
|
214
|
+
gitVer = NONSEMVER
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} catch { /* no tags → null = UNTAGGD */ }
|
|
218
|
+
} else {
|
|
219
|
+
// git describe gives the nearest tag reachable from HEAD, matching original pkgck behavior
|
|
220
|
+
try {
|
|
221
|
+
const tag = ( await git.raw( [ 'describe', '--abbrev=0', '--tags' ] ) ).trim()
|
|
222
|
+
// Handles 'v1.2.3' and workspace-style 'pkg-name/v1.2.3'
|
|
223
|
+
const match = tag.match( /(?:^|\/)v(\d+\.\d+\.\d+)$/ )
|
|
224
|
+
gitVer = match ? `v${match[1]}` : NONSEMVER
|
|
225
|
+
} catch { /* no tags → null = UNTAGGD */ }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let needsPush = ''
|
|
229
|
+
if ( pkgVer && pkgVer !== NONSEMVER && gitVer === pkgVer ) {
|
|
230
|
+
try {
|
|
231
|
+
const pkgBaseName = ( pkg?.name ?? path.basename( dir ) ).replace( /^@[^/]+\//, '' )
|
|
232
|
+
const tagToCheck = isWorkspace ? `${pkgBaseName}/${pkgVer}` : pkgVer
|
|
233
|
+
const remote = await git.listRemote( [ '--tags', 'origin' ] )
|
|
234
|
+
if ( !remote.includes( tagToCheck ) ) needsPush = chalk.yellow( '!tags' )
|
|
235
|
+
} catch { /* ignore */ }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const isYarn = fs.existsSync( path.join( gitRoot, 'yarn.lock' ) ) || fs.existsSync( path.join( gitRoot, '.yarn' ) )
|
|
239
|
+
const pkgMgr = isYarn ? chalk.cyan( 'yrn' ) : chalk.red( 'npm' )
|
|
240
|
+
|
|
241
|
+
const { repoName, group } = repoLabel( dir, gitRoot )
|
|
242
|
+
return {
|
|
243
|
+
repoName,
|
|
244
|
+
group,
|
|
245
|
+
pkgMgr,
|
|
246
|
+
pkgVer : fmtPkgVer( pkgVer ),
|
|
247
|
+
gitVer : fmtGitVer( gitVer, pkgVer ),
|
|
248
|
+
dirty : !syncOk,
|
|
249
|
+
scope : fmtScope( pkg ),
|
|
250
|
+
isWorkspace,
|
|
251
|
+
checks : CHECKS.map( ( { label, fn } ) => ( { label, ...fn( { dir, pkg, isWorkspace, gitRoot } ) } ) ),
|
|
252
|
+
needsPush,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------- Table ----------
|
|
257
|
+
|
|
258
|
+
function renderTable( rows ) {
|
|
259
|
+
const pkgW = Math.max( 3, ...rows.map( r => visLen( r.pkgVer ) ) )
|
|
260
|
+
const gitW = Math.max( 7, ...rows.map( r => visLen( r.gitVer ) ) )
|
|
261
|
+
const scopeW = Math.max( 4, ...rows.map( r => visLen( r.scope ) ) )
|
|
262
|
+
|
|
263
|
+
// Fixed width of all columns except name. Elements: mgr pkg git scope checks (no needsPush).
|
|
264
|
+
// join(' ') puts 2-space sep between N+5 elements = N+4 separators.
|
|
265
|
+
const checkLabelW = CHECKS.reduce( ( sum, c ) => sum + c.label.length, 0 )
|
|
266
|
+
const fixedW = 3 + pkgW + gitW + scopeW + checkLabelW + ( CHECKS.length + 4 ) * 2
|
|
267
|
+
|
|
268
|
+
// Name area: 2-space indent + repo name. Group label prints on its own line above each section.
|
|
269
|
+
const hasGroups = rows.some( r => r.group )
|
|
270
|
+
const indent = hasGroups ? 2 : 0
|
|
271
|
+
const maxRepoW = Math.min( 22, Math.max( 4, ...rows.map( r => r.repoName.length ) ) )
|
|
272
|
+
const nameW = Math.min( indent + maxRepoW, 80 - fixedW )
|
|
273
|
+
const repoW = nameW - indent
|
|
274
|
+
|
|
275
|
+
const nameHdr = pad( `${' '.repeat( indent )}name`, nameW )
|
|
276
|
+
const hdr = [
|
|
277
|
+
nameHdr,
|
|
278
|
+
center( 'mgr', 3 ),
|
|
279
|
+
center( 'pkg', pkgW ),
|
|
280
|
+
center( 'git', gitW ),
|
|
281
|
+
center( 'scop', scopeW ),
|
|
282
|
+
...CHECKS.map( c => center( c.label, c.label.length ) ),
|
|
283
|
+
].join( ' ' )
|
|
284
|
+
|
|
285
|
+
const separator = chalk.dim( '─'.repeat( visLen( hdr ) ) )
|
|
286
|
+
|
|
287
|
+
log( '' )
|
|
288
|
+
log( chalk.dim( hdr ) )
|
|
289
|
+
log( separator )
|
|
290
|
+
|
|
291
|
+
let currentGroup
|
|
292
|
+
for ( const row of rows ) {
|
|
293
|
+
if ( row.group !== currentGroup ) {
|
|
294
|
+
currentGroup = row.group
|
|
295
|
+
if ( hasGroups ) log( chalk.cyan( row.group ?? '' ) )
|
|
296
|
+
}
|
|
297
|
+
const nameStr = ' '.repeat( indent ) + row.repoName.slice( 0, repoW )
|
|
298
|
+
const nameCell = row.dirty ? chalk.hex( '#FFA500' )( nameStr ) : nameStr
|
|
299
|
+
const line = [
|
|
300
|
+
pad( nameCell, nameW ),
|
|
301
|
+
pad( row.pkgMgr, 3 ),
|
|
302
|
+
pad( row.pkgVer, pkgW ),
|
|
303
|
+
pad( row.gitVer, gitW ),
|
|
304
|
+
pad( row.scope, scopeW ),
|
|
305
|
+
...row.checks.map( c => center( c.value, c.label.length ) ),
|
|
306
|
+
].join( ' ' ) + ( row.needsPush ? ` ${row.needsPush}` : '' )
|
|
307
|
+
log( line )
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
log( '' )
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------- CLI ----------
|
|
314
|
+
|
|
315
|
+
program
|
|
316
|
+
.name( 'package-checker' )
|
|
317
|
+
.description( chalk.green( 'A panopticon for your git repos!' ) )
|
|
318
|
+
.argument( '<dirs...>', 'directories to check' )
|
|
319
|
+
.option( '--no-pull', 'skip git pull and tag fetch' )
|
|
320
|
+
.option( '--no-clear', 'do not clear the screen before output' )
|
|
321
|
+
.option( '--concurrency <n>', 'max parallel repos', '5' )
|
|
322
|
+
.option( '--limit <n>', 'max rows to display', '80' )
|
|
323
|
+
.option( '--debug', 'enable debug logging' )
|
|
324
|
+
.addHelpText( 'after', `
|
|
325
|
+
${chalk.yellow( 'Columns:' )}
|
|
326
|
+
${chalk.green( 'pkg' )} version from package.json
|
|
327
|
+
${chalk.green( 'git' )} latest semver git tag (green=match, yellow=mismatch, red=untagged)
|
|
328
|
+
${chalk.green( 'name' )} orange if dirty: pull/fetch failed (regular repos) or local modifications present (workspace packages)
|
|
329
|
+
${chalk.green( 'scope' )} package scope: @org, PRVT, or PUBL
|
|
330
|
+
${chalk.green( 'cir' )} CircleCI config: ✘=missing, L=legacy, 1=orb v1, 2=orb v2
|
|
331
|
+
${chalk.green( 'ESM' )} "type": "module" in package.json
|
|
332
|
+
${chalk.green( 'har' )} .har directory present at repo root
|
|
333
|
+
${chalk.green( 'lnt' )} ESLint version from node_modules (✘=no lint script, ?=not installed, yellow=v8, green=v9+)
|
|
334
|
+
|
|
335
|
+
${chalk.yellow( 'Monorepos:' )}
|
|
336
|
+
Workspace packages are discovered automatically — passing a monorepo root
|
|
337
|
+
expands its workspaces field so you never need to add packages/* globs.
|
|
338
|
+
Git tags are looked up as pkg-name/vX.Y.Z. Pull/fetch runs at the root.
|
|
339
|
+
|
|
340
|
+
${chalk.yellow( 'Examples:' )}
|
|
341
|
+
${chalk.green( 'package-checker ~/repos/my-service' )}
|
|
342
|
+
${chalk.green( 'package-checker --no-pull ~/repos/*' )}
|
|
343
|
+
${chalk.green( 'package-checker --concurrency 10 @plt/* @kud/* @srv/* @tpi/*' )}
|
|
344
|
+
` )
|
|
345
|
+
.parse()
|
|
346
|
+
|
|
347
|
+
const dirs = program.args
|
|
348
|
+
const options = program.opts()
|
|
349
|
+
if ( options.debug ) { enableDebug() }
|
|
350
|
+
|
|
351
|
+
if ( options.clear ) { clear() }
|
|
352
|
+
|
|
353
|
+
// Auto-expand workspace packages from any monorepo roots in the input.
|
|
354
|
+
// Supports both array and yarn-style { packages: [...] } workspaces fields.
|
|
355
|
+
async function expandWorkspaces( dir ) {
|
|
356
|
+
try {
|
|
357
|
+
const pkg = await parseJsonFile( path.join( dir, 'package.json' ) )
|
|
358
|
+
const patterns = Array.isArray( pkg?.workspaces ) ?
|
|
359
|
+
pkg.workspaces :
|
|
360
|
+
( pkg?.workspaces?.packages ?? [] )
|
|
361
|
+
if ( !patterns.length ) return []
|
|
362
|
+
const matches = await Promise.all(
|
|
363
|
+
patterns.map( p => glob( p, { cwd : dir, absolute : true } ) )
|
|
364
|
+
)
|
|
365
|
+
return matches.flat().filter( d => !/test|demo/i.test( path.basename( d ) ) )
|
|
366
|
+
} catch {
|
|
367
|
+
return []
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const resolvedDirs = dirs.map( dir => path.resolve( dir ) )
|
|
372
|
+
const wsExpansions = ( await Promise.all( resolvedDirs.map( expandWorkspaces ) ) ).flat()
|
|
373
|
+
const allDirs = [ ...new Set( [ ...resolvedDirs, ...wsExpansions ] ) ]
|
|
374
|
+
|
|
375
|
+
const total = allDirs.length
|
|
376
|
+
let completed = 0
|
|
377
|
+
const spinner = ora( `Checking repos (0/${total})...` ).start()
|
|
378
|
+
|
|
379
|
+
process.on( 'SIGINT', () => { spinner.stop(); process.exit( 1 ) } )
|
|
380
|
+
|
|
381
|
+
const limit = pLimit( parseInt( options.concurrency, 10 ) )
|
|
382
|
+
const results = await Promise.all(
|
|
383
|
+
allDirs.map( dir => limit( async () => {
|
|
384
|
+
const result = await checkRepo( dir, { pull : options.pull } )
|
|
385
|
+
spinner.text = `Checking repos (${++completed}/${total})...`
|
|
386
|
+
return result
|
|
387
|
+
} ) )
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
spinner.stop()
|
|
391
|
+
|
|
392
|
+
const rows = results
|
|
393
|
+
.filter( Boolean )
|
|
394
|
+
.sort( ( a, b ) => {
|
|
395
|
+
const ga = a.group ?? ''
|
|
396
|
+
const gb = b.group ?? ''
|
|
397
|
+
if ( ga !== gb ) return ga.localeCompare( gb )
|
|
398
|
+
return a.repoName.localeCompare( b.repoName )
|
|
399
|
+
} )
|
|
400
|
+
|
|
401
|
+
if ( rows.length === 0 ) {
|
|
402
|
+
log( chalk.yellow( '\nNo valid repositories found.\n' ) )
|
|
403
|
+
process.exit( 0 )
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const maxRows = parseInt( options.limit, 10 )
|
|
407
|
+
const checked = rows.length
|
|
408
|
+
const displayed = rows.slice( 0, maxRows )
|
|
409
|
+
|
|
410
|
+
renderTable( displayed )
|
|
411
|
+
|
|
412
|
+
const summary = checked > displayed.length ?
|
|
413
|
+
`total repositories checked: ${checked} (${displayed.length} displayed)\n` :
|
|
414
|
+
`total repositories checked: ${checked}\n`
|
|
415
|
+
log( summary )
|