forked from mxx1111/sparepack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsparepack.mjs
More file actions
executable file
·262 lines (227 loc) · 8.84 KB
/
Copy pathsparepack.mjs
File metadata and controls
executable file
·262 lines (227 loc) · 8.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
#!/usr/bin/env node
// sparepack CLI.
import { createInterface } from 'node:readline/promises'
import { access, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { stdin, stdout } from 'node:process'
import { CONFIG_NAMES, ConfigError, loadConfig } from '../src/config.mjs'
import { buildManifest, buildPack, hasBlockingFindings, renderManifest, writePack } from '../src/pack.mjs'
import { verifyPack } from '../src/verify.mjs'
import { FixtureError } from '../src/fixtures.mjs'
import { UnsupportedLanguageError } from '../src/interfaces.mjs'
const USAGE = `sparepack — turn a slice of a private repo into a shareable task pack
sparepack init write a commented sparepack.yaml to get started
sparepack pack [--yes] build a pack, show what it contains, ask before writing
sparepack verify <dir> re-check a built pack from scratch
Options
-c, --config <path> config file (default: ./sparepack.yaml)
-o, --out <dir> output directory (default: from config, else ./sparepack-out)
--yes skip the confirmation prompt (see the warning it prints)
--allow-findings build even with critical/high scan findings
--no-color plain output
sparepack is allowlist-only: nothing is published unless you named it.
`
const TEMPLATE = `# sparepack — what to hand a stranger, and nothing more.
#
# This file is an allowlist. There is no "exclude" key on purpose: "publish everything
# except..." means a file you never thought about gets published, and that is how leaks
# happen. Here, a file you never thought about stays home.
# One line. The worker reads this before anything else.
task: "Stream large CSV imports instead of loading the whole file into memory"
# Published byte for byte. Types, constants, contracts — things you would be comfortable
# posting publicly on their own.
include:
- src/importer/types.ts
# Signatures and types are kept; every function body is replaced with a throwing stub.
# TypeScript and JavaScript only — sparepack refuses other languages rather than
# risk shipping their implementations unchanged.
interfaces:
- src/importer/parser.ts
# The acceptance tests. These *are* the specification: if they pass, the task is done.
# A pack without tests puts the definition of "done" in prose, and disputes follow.
tests:
- tests/importer/*.spec.ts
# Real structure, synthetic values. Generators: empty | shape[:n] | rows:n | text:n
# shape reads the real JSON and rebuilds it with fake values, same keys and nesting
# rows keeps a delimited file's header row and generates n fake data rows
fixtures:
data/orders.json: shape:5
# Applied to every file before scanning. Use for names the scanner cannot know about:
# your company, internal project codenames, product names under NDA.
redact:
- pattern: "acme-corp|ACME"
replace: "example-org"
# Extra scan rules, on top of the built-in credential and PII patterns.
# scanRules:
# - id: internal-service
# pattern: "\\\\b(billing|ledger)-internal\\\\b"
# severity: high
# Findings you have looked at and decided are fine. Format: rule-id:path[:line]
# Do not add entries here to make the scanner quiet. Add them when you have read the
# specific line and concluded it is genuinely safe to publish.
# allowFindings:
# - email:src/importer/types.ts:12
out: sparepack-out
`
function parseArgs(argv) {
const opts = { yes: false, allowFindings: false, color: stdout.isTTY }
const positional = []
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--yes' || arg === '-y') opts.yes = true
else if (arg === '--allow-findings') opts.allowFindings = true
else if (arg === '--no-color') opts.color = false
else if (arg === '--help' || arg === '-h') opts.help = true
else if (arg === '--config' || arg === '-c') opts.config = argv[++i]
else if (arg === '--out' || arg === '-o') opts.out = argv[++i]
else if (arg.startsWith('-')) throw new Error(`unknown option "${arg}"`)
else positional.push(arg)
}
return { opts, positional }
}
async function findConfig(explicit) {
if (explicit) return resolve(explicit)
for (const name of CONFIG_NAMES) {
const candidate = resolve(process.cwd(), name)
try {
await access(candidate)
return candidate
} catch {
// keep looking
}
}
return resolve(process.cwd(), CONFIG_NAMES[0])
}
async function confirm(question) {
if (!stdin.isTTY) {
throw new Error(
'confirmation required but stdin is not a terminal. ' +
'Run this interactively, or pass --yes if you have reviewed the manifest another way.',
)
}
const rl = createInterface({ input: stdin, output: stdout })
try {
const answer = await rl.question(question)
return answer.trim()
} finally {
rl.close()
}
}
async function cmdInit(opts) {
const target = resolve(process.cwd(), opts.config ?? CONFIG_NAMES[0])
try {
await access(target)
console.error(`${target} already exists. Delete it first if you want a fresh template.`)
return 1
} catch {
// does not exist, good
}
await writeFile(target, TEMPLATE)
console.log(`Wrote ${target}
Edit it, then run "sparepack pack". Two things worth knowing before you do:
- Only what you list gets published. Patterns that match nothing are an error,
not a silent no-op, because a typo would otherwise ship less than you meant.
- You will be shown every file and asked to confirm before anything is written.`)
return 0
}
async function cmdPack(opts) {
const configPath = await findConfig(opts.config)
const root = process.cwd()
const config = await loadConfig(configPath)
const built = await buildPack(root, config)
const manifest = buildManifest(config, built)
const outDir = resolve(root, opts.out ?? config.out)
console.log(renderManifest(manifest, { color: opts.color }))
console.log(`\nOutput directory: ${outDir}`)
const blocking = hasBlockingFindings(built.findings)
if (blocking && !opts.allowFindings) {
console.error(
`\nRefusing to write: the scan found credentials or personal data in what you are about to publish.\n` +
`Fix the source, add a redact rule, or — only if you have read the specific lines and they are\n` +
`genuinely safe — list them under allowFindings in ${configPath}.\n` +
`\n--allow-findings overrides this. It exists for the case where the scanner is wrong, not for\n` +
`the case where you are in a hurry.`,
)
return 2
}
if (opts.yes) {
console.log(
`\nWriting without confirmation (--yes). The manifest above is the only record of what ` +
`was published; nobody looked at it.`,
)
} else {
const question =
`\nWould you be comfortable posting the file list above in public?\n` +
`Type "publish" to write the pack, anything else to abort: `
const answer = await confirm(question)
if (answer !== 'publish') {
console.log('Aborted. Nothing was written.')
return 1
}
}
const written = await writePack(outDir, manifest, built.files)
console.log(`\nWrote ${built.files.length} file(s) to ${written}`)
console.log(`Check it with: sparepack verify ${outDir}`)
return 0
}
async function cmdVerify(dir) {
if (!dir) {
console.error('sparepack verify needs a directory: sparepack verify ./sparepack-out')
return 1
}
const target = resolve(process.cwd(), dir)
const result = await verifyPack(target)
console.log(`Verified ${result.scanned} file(s) in ${target}`)
for (const note of result.notes) console.log(` note: ${note}`)
if (result.ok) {
console.log('\nNo problems found. Re-scanned from disk, manifest matches, no implementation survived stripping.')
return 0
}
console.error(`\n${result.problems.length} problem(s):`)
for (const p of result.problems) console.error(` ${p}`)
console.error('\nDo not publish this pack until these are resolved.')
return 2
}
async function main() {
let parsed
try {
parsed = parseArgs(process.argv.slice(2))
} catch (err) {
console.error(err.message)
console.error(`\n${USAGE}`)
return 1
}
const { opts, positional } = parsed
const command = positional[0]
// Asking for help is a successful use of the tool; being given no command is not.
if (opts.help) {
console.log(USAGE)
return 0
}
if (!command) {
console.log(USAGE)
return 1
}
switch (command) {
case 'init':
return cmdInit(opts)
case 'pack':
return cmdPack(opts)
case 'verify':
return cmdVerify(positional[1])
default:
console.error(`unknown command "${command}"\n\n${USAGE}`)
return 1
}
}
try {
process.exitCode = await main()
} catch (err) {
if (err instanceof ConfigError || err instanceof FixtureError || err instanceof UnsupportedLanguageError) {
console.error(`\n${err.message}`)
process.exitCode = 2
} else {
console.error(err)
process.exitCode = 1
}
}