model-eol 0.2.3 → 0.3.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/README.md +114 -26
- package/action.yml +124 -9
- package/bot/bot.mjs +978 -0
- package/bot/lib/common.mjs +120 -0
- package/bot/lib/config.mjs +1 -0
- package/bot/lib/eval.mjs +97 -0
- package/bot/lib/feeds.mjs +48 -0
- package/bot/lib/git.mjs +128 -0
- package/bot/lib/github.mjs +196 -0
- package/bot.yml.example +112 -0
- package/check.mjs +147 -42
- package/lib/config.mjs +133 -0
- package/lib/glob.mjs +35 -0
- package/lib/scanner.mjs +64 -5
- package/package.json +8 -4
- package/schema/model-eol.bot-config.schema.json +12 -8
package/bot.yml.example
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
name: model-eol bot
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: '17 4 * * 1'
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: model-eol-bot
|
|
10
|
+
cancel-in-progress: false
|
|
11
|
+
|
|
12
|
+
env:
|
|
13
|
+
# Major-line pin: receives compatible fixes and refreshed bundled feeds.
|
|
14
|
+
MODEL_EOL_PACKAGE: model-eol@0
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
plan:
|
|
18
|
+
name: plan and evaluate
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
steps:
|
|
23
|
+
# The eval job must never hold write tokens.
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
with:
|
|
26
|
+
persist-credentials: false
|
|
27
|
+
- uses: actions/setup-node@v4
|
|
28
|
+
with:
|
|
29
|
+
node-version: 20
|
|
30
|
+
- name: Build the versioned plan
|
|
31
|
+
run: |
|
|
32
|
+
PLAN_ARGS=(plan .)
|
|
33
|
+
if [ ! -f .model-eol.json ]; then PLAN_ARGS+=(--days 90 --scope direct); fi
|
|
34
|
+
npx --yes --package="$MODEL_EOL_PACKAGE" model-eol "${PLAN_ARGS[@]}" > model-eol-plan.json
|
|
35
|
+
- name: Run the configured eval harness
|
|
36
|
+
env:
|
|
37
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
38
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
39
|
+
MODEL_EOL_EVAL_COMMAND: ${{ vars.MODEL_EOL_EVAL_COMMAND }}
|
|
40
|
+
MODEL_EOL_PLAN: ${{ github.workspace }}/model-eol-plan.json
|
|
41
|
+
MODEL_EOL_REPORT: ${{ github.workspace }}/model-eol-report.md
|
|
42
|
+
run: |
|
|
43
|
+
# The workflow variable is an explicit override. Otherwise, use the
|
|
44
|
+
# eval.command already validated from .model-eol.json by the plan step.
|
|
45
|
+
if [ -z "${MODEL_EOL_EVAL_COMMAND:-}" ] && [ -f .model-eol.json ]; then
|
|
46
|
+
MODEL_EOL_EVAL_COMMAND="$(node --input-type=module -e '
|
|
47
|
+
import fs from "node:fs"
|
|
48
|
+
const config = JSON.parse(fs.readFileSync(".model-eol.json", "utf8"))
|
|
49
|
+
if (typeof config.eval?.command === "string") process.stdout.write(config.eval.command)
|
|
50
|
+
')"
|
|
51
|
+
fi
|
|
52
|
+
# Supply a provider-backed harness for this repository. It may read the
|
|
53
|
+
# plan and write Markdown to MODEL_EOL_REPORT, but this job has read-only
|
|
54
|
+
# contents permission and the publish job receives no provider keys.
|
|
55
|
+
if [ -n "${MODEL_EOL_EVAL_COMMAND:-}" ]; then
|
|
56
|
+
set +e
|
|
57
|
+
sh -c "$MODEL_EOL_EVAL_COMMAND"
|
|
58
|
+
eval_status=$?
|
|
59
|
+
set -e
|
|
60
|
+
printf '%s\n' "$eval_status" > model-eol-eval-exit-code
|
|
61
|
+
else
|
|
62
|
+
: > "$MODEL_EOL_REPORT"
|
|
63
|
+
printf '0\n' > model-eol-eval-exit-code
|
|
64
|
+
fi
|
|
65
|
+
- name: Upload plan and eval report
|
|
66
|
+
uses: actions/upload-artifact@v4
|
|
67
|
+
with:
|
|
68
|
+
name: model-eol-plan-and-report
|
|
69
|
+
path: |
|
|
70
|
+
model-eol-plan.json
|
|
71
|
+
model-eol-report.md
|
|
72
|
+
model-eol-eval-exit-code
|
|
73
|
+
|
|
74
|
+
publish:
|
|
75
|
+
name: publish migration work
|
|
76
|
+
needs: plan
|
|
77
|
+
runs-on: ubuntu-latest
|
|
78
|
+
permissions:
|
|
79
|
+
contents: write
|
|
80
|
+
pull-requests: write
|
|
81
|
+
issues: write
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v4
|
|
84
|
+
with:
|
|
85
|
+
# model-eol-bot authenticates its own temporary HTTPS clones and
|
|
86
|
+
# pushes with GITHUB_TOKEN; no checkout credential is persisted.
|
|
87
|
+
persist-credentials: false
|
|
88
|
+
- uses: actions/setup-node@v4
|
|
89
|
+
with:
|
|
90
|
+
node-version: 20
|
|
91
|
+
- name: Download the plan and eval report
|
|
92
|
+
uses: actions/download-artifact@v4
|
|
93
|
+
with:
|
|
94
|
+
name: model-eol-plan-and-report
|
|
95
|
+
- name: Publish model-eol PRs and issues
|
|
96
|
+
env:
|
|
97
|
+
GITHUB_TOKEN: ${{ secrets.MODEL_EOL_BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
|
98
|
+
MODEL_EOL_PLAN_FILE: ${{ github.workspace }}/model-eol-plan.json
|
|
99
|
+
MODEL_EOL_EVAL_REPORT_FILE: ${{ github.workspace }}/model-eol-report.md
|
|
100
|
+
MODEL_EOL_EVAL_STATUS_FILE: ${{ github.workspace }}/model-eol-eval-exit-code
|
|
101
|
+
MODEL_EOL_TOKEN_KIND: ${{ secrets.MODEL_EOL_BOT_TOKEN && 'external-token' || 'github-token' }}
|
|
102
|
+
run: npx --yes --package="$MODEL_EOL_PACKAGE" model-eol-bot --repo "${{ github.repository }}" --target-dir .
|
|
103
|
+
|
|
104
|
+
# Security and checks note:
|
|
105
|
+
# A PR made with GITHUB_TOKEN does not trigger pull_request workflows, so checks
|
|
106
|
+
# may be skipped even though this job has opened the PR. For required checks, use
|
|
107
|
+
# a narrowly scoped fine-grained PAT or a GitHub App installation token in the
|
|
108
|
+
# optional MODEL_EOL_BOT_TOKEN secret instead.
|
|
109
|
+
# Keep provider API keys only in the read-only plan/eval job and never pass them to
|
|
110
|
+
# the publish job.
|
|
111
|
+
# Set feeds.allow_vendored_fallback: true in .model-eol.json only for degraded report-only runs; the default is false.
|
|
112
|
+
# Independent concurrent invocations need external mutual exclusion; this tool does not lock.
|
package/check.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// model-eol reference CLI - zero dependencies.
|
|
3
3
|
//
|
|
4
4
|
// Default mode preserves the original CI gate:
|
|
5
|
-
// node check.mjs [paths...] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
|
|
5
|
+
// node check.mjs [paths...] [--config FILE] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
|
|
6
6
|
//
|
|
7
7
|
// New inventory modes:
|
|
8
8
|
// node check.mjs inventory [paths...] [--json|--format json|cyclonedx|text]
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
// exit codes: check/alert 0 = clear, 1 = finding, 2 = usage error;
|
|
15
15
|
// apply 0 = all items applied, 1 = item refused, 2 = usage or plan error.
|
|
16
16
|
|
|
17
|
+
import fs from 'node:fs'
|
|
17
18
|
import path from 'node:path'
|
|
18
19
|
|
|
19
20
|
import { applyPlan } from './lib/apply.mjs'
|
|
20
21
|
import { parseCliArgs } from './lib/cli.mjs'
|
|
22
|
+
import { CLI_DEFAULT_CONFIG, DEFAULT_CONFIG, loadConfig, normalizeConfig } from './lib/config.mjs'
|
|
21
23
|
import { findingFromRef, isBad, loadFeeds } from './lib/feeds.mjs'
|
|
22
24
|
import { buildPlan } from './lib/plan.mjs'
|
|
23
25
|
import {
|
|
@@ -32,8 +34,46 @@ import {
|
|
|
32
34
|
formatInventoryCycloneDX,
|
|
33
35
|
formatSchedule,
|
|
34
36
|
} from './lib/reports.mjs'
|
|
35
|
-
import { addedLinesForTargets, filterFindingsToChanged, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
|
|
37
|
+
import { addedLinesForTargets, filterFindingsToChanged, gitRootFor, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
|
|
36
38
|
|
|
39
|
+
const rootForTarget = target => {
|
|
40
|
+
const absolute = path.resolve(target)
|
|
41
|
+
let stat
|
|
42
|
+
try {
|
|
43
|
+
stat = fs.lstatSync(absolute)
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
let resolved = absolute
|
|
48
|
+
try {
|
|
49
|
+
resolved = fs.realpathSync(absolute)
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const gitRoot = gitRootFor(resolved)
|
|
54
|
+
if (gitRoot) return gitRoot
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
return stat.isDirectory() ? resolved : path.dirname(resolved)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const configForTargets = ({ targets, explicit }) => {
|
|
61
|
+
const roots = [...new Set(targets.map(rootForTarget).filter(Boolean))]
|
|
62
|
+
if (explicit !== null) {
|
|
63
|
+
if (!explicit) throw new Error('--config must be a non-empty path')
|
|
64
|
+
return { file: path.resolve(explicit), roots }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const files = [...new Set(roots
|
|
68
|
+
.map(root => path.join(root, '.model-eol.json'))
|
|
69
|
+
.filter(file => fs.existsSync(file)))]
|
|
70
|
+
if (files.length > 1) {
|
|
71
|
+
throw new Error(`multiple .model-eol.json files found (${files.join(', ')}); use --config FILE to choose one`)
|
|
72
|
+
}
|
|
73
|
+
return { file: files[0] ?? null, roots }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const main = () => {
|
|
37
77
|
const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'help'])
|
|
38
78
|
const args = process.argv.slice(2)
|
|
39
79
|
|
|
@@ -45,6 +85,7 @@ try {
|
|
|
45
85
|
args,
|
|
46
86
|
options: {
|
|
47
87
|
days: { type: 'string' },
|
|
88
|
+
config: { type: 'string' },
|
|
48
89
|
feeds: { type: 'string' },
|
|
49
90
|
via: { type: 'string' },
|
|
50
91
|
scope: { type: 'string' },
|
|
@@ -62,16 +103,12 @@ try {
|
|
|
62
103
|
})
|
|
63
104
|
} catch (error) {
|
|
64
105
|
console.error(error.message)
|
|
65
|
-
|
|
106
|
+
return 2
|
|
66
107
|
}
|
|
67
108
|
|
|
68
109
|
const { values, positionals } = parsed
|
|
69
110
|
if (values.help) command = 'help'
|
|
70
111
|
|
|
71
|
-
const DAYS = Number(values.days ?? '90')
|
|
72
|
-
const FEEDS_DIR = values.feeds ?? path.join(import.meta.dirname, 'feeds')
|
|
73
|
-
const VIA = values.via ?? null // e.g. azure-ai-foundry, aws-bedrock
|
|
74
|
-
const SCOPE = values.scope ?? 'all'
|
|
75
112
|
const FORMAT = values.format ?? null
|
|
76
113
|
const PLAN_FILE = values.plan ?? null
|
|
77
114
|
const AS_JSON = values.json ?? false
|
|
@@ -85,12 +122,12 @@ if (command === 'help') {
|
|
|
85
122
|
console.log(`model-eol
|
|
86
123
|
|
|
87
124
|
Usage:
|
|
88
|
-
node check.mjs [paths...] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
|
|
89
|
-
node check.mjs check [paths...] [--days N] [--scope all|direct] [--json] [--changed BASE_REF] [--allow-incomplete]
|
|
90
|
-
node check.mjs inventory [paths...] [--json] [--format json|cyclonedx|text]
|
|
91
|
-
node check.mjs schedule [paths...] [--days N] [--json]
|
|
92
|
-
node check.mjs alert [paths...] [--days N] [--scope all|direct] [--format github|markdown|badge] [--json]
|
|
93
|
-
node check.mjs plan [paths...] [--days N] [--scope all|direct] [--via DISTRIBUTOR] [--feeds DIR] [--allow-incomplete]
|
|
125
|
+
node check.mjs [paths...] [--config FILE] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
|
|
126
|
+
node check.mjs check [paths...] [--config FILE] [--days N] [--scope all|direct] [--json] [--changed BASE_REF] [--allow-incomplete]
|
|
127
|
+
node check.mjs inventory [paths...] [--config FILE] [--json] [--format json|cyclonedx|text]
|
|
128
|
+
node check.mjs schedule [paths...] [--config FILE] [--days N] [--json]
|
|
129
|
+
node check.mjs alert [paths...] [--config FILE] [--days N] [--scope all|direct] [--format github|markdown|badge] [--json]
|
|
130
|
+
node check.mjs plan [paths...] [--config FILE] [--days N] [--scope all|direct] [--via DISTRIBUTOR] [--feeds DIR] [--allow-incomplete]
|
|
94
131
|
node check.mjs apply --plan plan.json [--dry-run]
|
|
95
132
|
|
|
96
133
|
Commands:
|
|
@@ -107,43 +144,70 @@ Commands:
|
|
|
107
144
|
Scopes:
|
|
108
145
|
all Check every tracked model ID found. This preserves the original behavior.
|
|
109
146
|
direct Check direct API and generic model references; leave cloud/gateway refs in inventory.
|
|
147
|
+
|
|
148
|
+
Configuration:
|
|
149
|
+
.model-eol.json is loaded automatically from the target repository root.
|
|
150
|
+
--config FILE selects an explicit config. Explicit CLI flags override config values.
|
|
151
|
+
|
|
152
|
+
Exit codes:
|
|
153
|
+
0 Clean, or report generated successfully.
|
|
154
|
+
1 Findings, alert errors, or refused apply items.
|
|
155
|
+
2 Usage, feed, scan, or plan errors.
|
|
156
|
+
3 Output stream failure other than EPIPE.
|
|
110
157
|
`)
|
|
111
|
-
|
|
158
|
+
return 0
|
|
112
159
|
}
|
|
113
160
|
|
|
114
161
|
if (command === 'apply') {
|
|
115
162
|
if (!PLAN_FILE || positionals.length) {
|
|
116
163
|
console.error('apply requires --plan plan.json and accepts no target paths')
|
|
117
|
-
|
|
164
|
+
return 2
|
|
118
165
|
}
|
|
119
166
|
try {
|
|
120
167
|
const result = applyPlan({ planPath: PLAN_FILE, dryRun: DRY_RUN, rootDir: process.cwd() })
|
|
121
|
-
|
|
168
|
+
return result.failed ? 1 : 0
|
|
122
169
|
} catch (e) {
|
|
123
170
|
console.error(`failed to apply plan ${PLAN_FILE}: ${e.message}`)
|
|
124
|
-
|
|
171
|
+
return 2
|
|
125
172
|
}
|
|
126
173
|
}
|
|
127
174
|
|
|
175
|
+
let configLocation
|
|
176
|
+
let repositoryConfig
|
|
177
|
+
try {
|
|
178
|
+
configLocation = configForTargets({ targets, explicit: values.config ?? null })
|
|
179
|
+
repositoryConfig = configLocation.file
|
|
180
|
+
? loadConfig(configLocation.file, { defaults: DEFAULT_CONFIG, allowMissing: false })
|
|
181
|
+
: normalizeConfig({}, { defaults: CLI_DEFAULT_CONFIG })
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.error(`failed to load repository config: ${e.message}`)
|
|
184
|
+
return 2
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const DAYS = Number(values.days ?? repositoryConfig.days)
|
|
188
|
+
const FEEDS_DIR = values.feeds ?? path.join(import.meta.dirname, 'feeds')
|
|
189
|
+
const VIA = values.via ?? repositoryConfig.via // e.g. azure-ai-foundry, aws-bedrock
|
|
190
|
+
const SCOPE = values.scope ?? repositoryConfig.scope
|
|
191
|
+
|
|
128
192
|
if (!Number.isFinite(DAYS) || !Number.isInteger(DAYS) || DAYS < 0) {
|
|
129
193
|
console.error('--days must be a finite non-negative integer')
|
|
130
|
-
|
|
194
|
+
return 2
|
|
131
195
|
}
|
|
132
196
|
if (!['all', 'direct'].includes(SCOPE)) {
|
|
133
197
|
console.error('--scope must be all or direct')
|
|
134
|
-
|
|
198
|
+
return 2
|
|
135
199
|
}
|
|
136
200
|
if (CHANGED_BASE !== null && command !== 'check') {
|
|
137
201
|
console.error('--changed is only supported by the check command')
|
|
138
|
-
|
|
202
|
+
return 2
|
|
139
203
|
}
|
|
140
204
|
if (command === 'alert' && FORMAT !== null && !['github', 'markdown', 'badge', 'json'].includes(FORMAT)) {
|
|
141
205
|
console.error('--format must be github, markdown, badge, or json for alert')
|
|
142
|
-
|
|
206
|
+
return 2
|
|
143
207
|
}
|
|
144
208
|
if (command === 'inventory' && FORMAT !== null && !['json', 'cyclonedx', 'text'].includes(FORMAT)) {
|
|
145
209
|
console.error('--format must be json, cyclonedx, or text for inventory')
|
|
146
|
-
|
|
210
|
+
return 2
|
|
147
211
|
}
|
|
148
212
|
|
|
149
213
|
let feedData
|
|
@@ -151,11 +215,11 @@ try {
|
|
|
151
215
|
feedData = loadFeeds(FEEDS_DIR)
|
|
152
216
|
} catch (e) {
|
|
153
217
|
console.error(`failed to load feeds from ${FEEDS_DIR}: ${e.message}`)
|
|
154
|
-
|
|
218
|
+
return 2
|
|
155
219
|
}
|
|
156
220
|
if (feedData.entries.size === 0) {
|
|
157
221
|
console.error(`no feed entries loaded from ${FEEDS_DIR}`)
|
|
158
|
-
|
|
222
|
+
return 2
|
|
159
223
|
}
|
|
160
224
|
|
|
161
225
|
let scan
|
|
@@ -165,10 +229,14 @@ try {
|
|
|
165
229
|
entries: feedData.entries,
|
|
166
230
|
keys: feedData.keys,
|
|
167
231
|
includeDocs: INCLUDE_DOCS,
|
|
232
|
+
ignoreModels: repositoryConfig.ignore.models,
|
|
233
|
+
ignorePaths: repositoryConfig.ignore.paths,
|
|
234
|
+
ignoreRoots: configLocation.roots,
|
|
235
|
+
ignoredFiles: configLocation.file ? [configLocation.file] : [],
|
|
168
236
|
})
|
|
169
237
|
} catch (e) {
|
|
170
238
|
console.error(`scan failed: ${e.message}`)
|
|
171
|
-
|
|
239
|
+
return 2
|
|
172
240
|
}
|
|
173
241
|
|
|
174
242
|
const incompleteNotes = incompleteScanNotes(scan.notes)
|
|
@@ -179,23 +247,22 @@ if (incompleteNotes.length && (command === 'check' || command === 'plan')) {
|
|
|
179
247
|
const location = note.file ? ` (${note.file})` : ''
|
|
180
248
|
console.error(` ${note.reason}${location}${note.message ? `: ${note.message}` : ''}`)
|
|
181
249
|
}
|
|
182
|
-
if (!ALLOW_INCOMPLETE)
|
|
250
|
+
if (!ALLOW_INCOMPLETE) return 2
|
|
183
251
|
}
|
|
184
252
|
|
|
185
253
|
const findings = scan.modelRefs.map(ref => findingFromRef(ref, { days: DAYS, via: VIA }))
|
|
186
254
|
const checkFindings = SCOPE === 'direct'
|
|
187
255
|
? findings.filter(f => f.usage === 'direct-api' || f.usage === 'model-reference')
|
|
188
256
|
: findings
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
})()
|
|
257
|
+
let changedFindings = checkFindings
|
|
258
|
+
if (CHANGED_BASE !== null) {
|
|
259
|
+
try {
|
|
260
|
+
changedFindings = filterFindingsToChanged(checkFindings, addedLinesForTargets(targets, CHANGED_BASE))
|
|
261
|
+
} catch (e) {
|
|
262
|
+
console.error(`--changed failed: ${e.message}`)
|
|
263
|
+
return 2
|
|
264
|
+
}
|
|
265
|
+
}
|
|
199
266
|
const bad = changedFindings.filter(isBad)
|
|
200
267
|
const inventory = () => buildInventory({ scan, findings, days: DAYS, via: VIA, scope: SCOPE, targets })
|
|
201
268
|
const schedule = () => buildSchedule(inventory())
|
|
@@ -210,7 +277,7 @@ if (command === 'plan') {
|
|
|
210
277
|
via: VIA,
|
|
211
278
|
scope: SCOPE,
|
|
212
279
|
}), null, 2))
|
|
213
|
-
|
|
280
|
+
return 0
|
|
214
281
|
}
|
|
215
282
|
|
|
216
283
|
if (command === 'check') {
|
|
@@ -219,7 +286,7 @@ if (command === 'check') {
|
|
|
219
286
|
} else {
|
|
220
287
|
console.log(formatCheck({ findings: changedFindings, bad, scannedFiles: scan.files.length, days: DAYS, scope: SCOPE }))
|
|
221
288
|
}
|
|
222
|
-
|
|
289
|
+
return bad.length ? 1 : 0
|
|
223
290
|
}
|
|
224
291
|
|
|
225
292
|
if (command === 'inventory') {
|
|
@@ -232,7 +299,7 @@ if (command === 'inventory') {
|
|
|
232
299
|
} else {
|
|
233
300
|
console.log(formatInventory(inv, DAYS))
|
|
234
301
|
}
|
|
235
|
-
|
|
302
|
+
return 0
|
|
236
303
|
}
|
|
237
304
|
|
|
238
305
|
if (command === 'schedule') {
|
|
@@ -242,7 +309,7 @@ if (command === 'schedule') {
|
|
|
242
309
|
} else {
|
|
243
310
|
console.log(formatSchedule(sched, DAYS))
|
|
244
311
|
}
|
|
245
|
-
|
|
312
|
+
return 0
|
|
246
313
|
}
|
|
247
314
|
|
|
248
315
|
if (command === 'alert') {
|
|
@@ -256,8 +323,46 @@ if (command === 'alert') {
|
|
|
256
323
|
} else {
|
|
257
324
|
console.log(formatAlertGithub(payload, DAYS))
|
|
258
325
|
}
|
|
259
|
-
|
|
326
|
+
return payload.errors.length ? 1 : 0
|
|
260
327
|
}
|
|
261
328
|
|
|
262
329
|
console.error(`unknown command: ${command}`)
|
|
263
|
-
|
|
330
|
+
return 2
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const writeAndCapture = (stream, chunk) => new Promise(resolve => {
|
|
334
|
+
let error = null
|
|
335
|
+
let settling = false
|
|
336
|
+
const settle = nextError => {
|
|
337
|
+
if (nextError && !error) error = nextError
|
|
338
|
+
if (settling) return
|
|
339
|
+
settling = true
|
|
340
|
+
setImmediate(() => {
|
|
341
|
+
stream.off('error', onError)
|
|
342
|
+
resolve(error)
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
const onError = nextError => settle(nextError)
|
|
346
|
+
stream.on('error', onError)
|
|
347
|
+
try {
|
|
348
|
+
stream.write(chunk, settle)
|
|
349
|
+
} catch (writeError) {
|
|
350
|
+
settle(writeError)
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
const drainStream = stream => writeAndCapture(stream, '')
|
|
354
|
+
|
|
355
|
+
const exitCode = main()
|
|
356
|
+
const [stdoutError, stderrError] = await Promise.all([
|
|
357
|
+
drainStream(process.stdout),
|
|
358
|
+
drainStream(process.stderr),
|
|
359
|
+
])
|
|
360
|
+
const outputError = [stdoutError, stderrError].find(error => error && error.code !== 'EPIPE')
|
|
361
|
+
let finalExitCode = exitCode
|
|
362
|
+
if (exitCode === 0 && outputError) {
|
|
363
|
+
finalExitCode = 3
|
|
364
|
+
const diagnosticStream = stderrError ? (stdoutError ? null : process.stdout) : process.stderr
|
|
365
|
+
const detail = String(outputError.code ?? outputError.message ?? 'unknown error').replace(/[\r\n]+/g, ' ')
|
|
366
|
+
if (diagnosticStream) await writeAndCapture(diagnosticStream, `model-eol: output failed: ${detail}\n`)
|
|
367
|
+
}
|
|
368
|
+
process.exit(finalExitCode)
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { MAX_REPORT_BYTES, MAX_TIMEOUT_MS, MIN_TIMEOUT_MS } from './validate-feed.mjs'
|
|
4
|
+
|
|
5
|
+
const frozenArray = () => Object.freeze([])
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
8
|
+
days: 90,
|
|
9
|
+
scope: 'direct',
|
|
10
|
+
via: null,
|
|
11
|
+
feeds: Object.freeze({ allow_vendored_fallback: false }),
|
|
12
|
+
ignore: Object.freeze({ models: frozenArray(), paths: frozenArray() }),
|
|
13
|
+
issues: Object.freeze({ enabled: true }),
|
|
14
|
+
eval: Object.freeze({ command: null, timeout_ms: 600000, max_report_bytes: 65536, pass_env: frozenArray() }),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const CLI_DEFAULT_CONFIG = Object.freeze({
|
|
18
|
+
...DEFAULT_CONFIG,
|
|
19
|
+
scope: 'all',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const copyDefaults = defaults => ({
|
|
23
|
+
days: defaults.days,
|
|
24
|
+
scope: defaults.scope,
|
|
25
|
+
via: defaults.via,
|
|
26
|
+
feeds: { allow_vendored_fallback: defaults.feeds.allow_vendored_fallback },
|
|
27
|
+
ignore: {
|
|
28
|
+
models: [...defaults.ignore.models],
|
|
29
|
+
paths: [...defaults.ignore.paths],
|
|
30
|
+
},
|
|
31
|
+
issues: { enabled: defaults.issues.enabled },
|
|
32
|
+
eval: {
|
|
33
|
+
command: defaults.eval.command,
|
|
34
|
+
timeout_ms: defaults.eval.timeout_ms,
|
|
35
|
+
max_report_bytes: defaults.eval.max_report_bytes,
|
|
36
|
+
pass_env: [...defaults.eval.pass_env],
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const assert = (condition, message) => {
|
|
41
|
+
if (!condition) throw new Error(`invalid .model-eol.json: ${message}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const assertKnownKeys = (value, label, allowed) => {
|
|
45
|
+
for (const key of Object.keys(value)) {
|
|
46
|
+
const fullKey = label ? `${label}.${key}` : key
|
|
47
|
+
assert(allowed.has(key), `unknown key "${fullKey}"`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const stringArray = (value, label) => {
|
|
52
|
+
assert(Array.isArray(value), `${label} must be an array`)
|
|
53
|
+
assert(value.every(item => typeof item === 'string' && item.length > 0), `${label} must contain non-empty strings`)
|
|
54
|
+
return value.slice()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CREDENTIAL_ENV_PATTERN = /^(?:GITHUB_|GH_|ACTIONS_|SSH_|AWS_SECRET)|(?:TOKEN|SECRET|PASSWORD|CREDENTIAL)/i
|
|
58
|
+
|
|
59
|
+
export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
|
|
60
|
+
assert(raw && typeof raw === 'object' && !Array.isArray(raw), 'root must be an object')
|
|
61
|
+
assertKnownKeys(raw, '', new Set(['days', 'scope', 'via', 'feeds', 'ignore', 'issues', 'eval']))
|
|
62
|
+
const config = copyDefaults(defaults)
|
|
63
|
+
|
|
64
|
+
if (raw.days !== undefined) {
|
|
65
|
+
assert(Number.isInteger(raw.days) && raw.days >= 0, 'days must be a non-negative integer')
|
|
66
|
+
config.days = raw.days
|
|
67
|
+
}
|
|
68
|
+
if (raw.scope !== undefined) {
|
|
69
|
+
assert(raw.scope === 'all' || raw.scope === 'direct', 'scope must be all or direct')
|
|
70
|
+
config.scope = raw.scope
|
|
71
|
+
}
|
|
72
|
+
if (raw.via !== undefined) {
|
|
73
|
+
assert(raw.via === null || (typeof raw.via === 'string' && raw.via.length > 0), 'via must be null or a non-empty string')
|
|
74
|
+
config.via = raw.via
|
|
75
|
+
}
|
|
76
|
+
if (raw.feeds !== undefined) {
|
|
77
|
+
assert(raw.feeds && typeof raw.feeds === 'object' && !Array.isArray(raw.feeds), 'feeds must be an object')
|
|
78
|
+
assertKnownKeys(raw.feeds, 'feeds', new Set(['allow_vendored_fallback']))
|
|
79
|
+
if (raw.feeds.allow_vendored_fallback !== undefined) {
|
|
80
|
+
assert(typeof raw.feeds.allow_vendored_fallback === 'boolean', 'feeds.allow_vendored_fallback must be boolean')
|
|
81
|
+
config.feeds.allow_vendored_fallback = raw.feeds.allow_vendored_fallback
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (raw.ignore !== undefined) {
|
|
85
|
+
assert(raw.ignore && typeof raw.ignore === 'object' && !Array.isArray(raw.ignore), 'ignore must be an object')
|
|
86
|
+
assertKnownKeys(raw.ignore, 'ignore', new Set(['models', 'paths']))
|
|
87
|
+
if (raw.ignore.models !== undefined) config.ignore.models = stringArray(raw.ignore.models, 'ignore.models')
|
|
88
|
+
if (raw.ignore.paths !== undefined) config.ignore.paths = stringArray(raw.ignore.paths, 'ignore.paths')
|
|
89
|
+
}
|
|
90
|
+
if (raw.issues !== undefined) {
|
|
91
|
+
assert(raw.issues && typeof raw.issues === 'object' && !Array.isArray(raw.issues), 'issues must be an object')
|
|
92
|
+
assertKnownKeys(raw.issues, 'issues', new Set(['enabled']))
|
|
93
|
+
if (raw.issues.enabled !== undefined) assert(typeof raw.issues.enabled === 'boolean', 'issues.enabled must be boolean')
|
|
94
|
+
if (raw.issues.enabled !== undefined) config.issues.enabled = raw.issues.enabled
|
|
95
|
+
}
|
|
96
|
+
if (raw.eval !== undefined) {
|
|
97
|
+
assert(raw.eval && typeof raw.eval === 'object' && !Array.isArray(raw.eval), 'eval must be an object')
|
|
98
|
+
assertKnownKeys(raw.eval, 'eval', new Set(['command', 'timeout_ms', 'max_report_bytes', 'pass_env']))
|
|
99
|
+
if (raw.eval.command !== undefined) {
|
|
100
|
+
assert(raw.eval.command === null || (typeof raw.eval.command === 'string' && raw.eval.command.length > 0), 'eval.command must be null or a non-empty string')
|
|
101
|
+
config.eval.command = raw.eval.command
|
|
102
|
+
}
|
|
103
|
+
if (raw.eval.timeout_ms !== undefined) {
|
|
104
|
+
assert(Number.isSafeInteger(raw.eval.timeout_ms) && raw.eval.timeout_ms >= MIN_TIMEOUT_MS && raw.eval.timeout_ms <= MAX_TIMEOUT_MS, `eval.timeout_ms must be a safe integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS}`)
|
|
105
|
+
config.eval.timeout_ms = raw.eval.timeout_ms
|
|
106
|
+
}
|
|
107
|
+
if (raw.eval.max_report_bytes !== undefined) {
|
|
108
|
+
assert(Number.isSafeInteger(raw.eval.max_report_bytes) && raw.eval.max_report_bytes > 0 && raw.eval.max_report_bytes <= MAX_REPORT_BYTES, `eval.max_report_bytes must be a safe positive integer <= ${MAX_REPORT_BYTES}`)
|
|
109
|
+
config.eval.max_report_bytes = raw.eval.max_report_bytes
|
|
110
|
+
}
|
|
111
|
+
if (raw.eval.pass_env !== undefined) {
|
|
112
|
+
const passEnv = stringArray(raw.eval.pass_env, 'eval.pass_env')
|
|
113
|
+
for (const name of passEnv) assert(!CREDENTIAL_ENV_PATTERN.test(name), `eval.pass_env variable "${name}" is not allowed`)
|
|
114
|
+
config.eval.pass_env = passEnv
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return config
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const loadConfig = (file, { defaults = DEFAULT_CONFIG, allowMissing = true } = {}) => {
|
|
122
|
+
if (!fs.existsSync(file)) {
|
|
123
|
+
if (allowMissing) return normalizeConfig({}, { defaults })
|
|
124
|
+
throw new Error(`could not read config ${file}: file not found`)
|
|
125
|
+
}
|
|
126
|
+
let value
|
|
127
|
+
try {
|
|
128
|
+
value = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new Error(`could not read config ${file}: ${error.message}`)
|
|
131
|
+
}
|
|
132
|
+
return normalizeConfig(value, { defaults })
|
|
133
|
+
}
|
package/lib/glob.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const escapeRegExp = value => value.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
2
|
+
|
|
3
|
+
export const normalizeRepoPath = value => String(value ?? '')
|
|
4
|
+
.replaceAll('\\', '/')
|
|
5
|
+
.replace(/^\.\//, '')
|
|
6
|
+
.replace(/^\/+/, '')
|
|
7
|
+
|
|
8
|
+
export const globRegex = pattern => {
|
|
9
|
+
let source = ''
|
|
10
|
+
const value = normalizeRepoPath(pattern)
|
|
11
|
+
for (let i = 0; i < value.length; i++) {
|
|
12
|
+
const char = value[i]
|
|
13
|
+
if (char === '*' && value[i + 1] === '*') {
|
|
14
|
+
if (value[i + 2] === '/') {
|
|
15
|
+
source += '(?:.*/)?'
|
|
16
|
+
i += 2
|
|
17
|
+
} else {
|
|
18
|
+
source += '.*'
|
|
19
|
+
i++
|
|
20
|
+
}
|
|
21
|
+
} else if (char === '*') {
|
|
22
|
+
source += '[^/]*'
|
|
23
|
+
} else if (char === '?') {
|
|
24
|
+
source += '[^/]'
|
|
25
|
+
} else {
|
|
26
|
+
source += escapeRegExp(char)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return new RegExp(`^${source}$`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const matchesGlob = (file, pattern) => globRegex(pattern).test(normalizeRepoPath(file))
|
|
33
|
+
|
|
34
|
+
export const matchesAnyGlob = (file, patterns = []) =>
|
|
35
|
+
patterns.some(pattern => matchesGlob(file, pattern))
|