@iceinvein/agent-skills 0.1.39 → 0.2.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 +18 -2
- package/dist/cli/index.js +105 -28
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +138 -45
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +117 -6
- package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
- package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
- package/skills/magpie/scripts/diff-chunks.ts +28 -0
- package/skills/magpie/scripts/findings-files.ts +32 -0
- package/skills/magpie/scripts/gh.ts +68 -14
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +32 -3
- package/skills/magpie/scripts/render-cmd.ts +35 -11
- package/skills/magpie/scripts/render-findings.ts +78 -2
- package/skills/magpie/scripts/render-progress.ts +6 -1
- package/skills/magpie/scripts/setup-cmd.ts +38 -1
- package/skills/magpie/scripts/shard.ts +171 -0
- package/skills/magpie/scripts/status-cmd.ts +7 -4
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +66 -0
- package/skills/migrate/README.md +194 -0
- package/skills/migrate/SKILL.md +197 -0
- package/skills/migrate/bin/migrate +15 -0
- package/skills/migrate/bin/migrate.ts +309 -0
- package/skills/migrate/biome.json +35 -0
- package/skills/migrate/bun.lock +24 -0
- package/skills/migrate/docs/architecture.md +294 -0
- package/skills/migrate/docs/reference.md +590 -0
- package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
- package/skills/migrate/fixtures/tiny-express/app.js +29 -0
- package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
- package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
- package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
- package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
- package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
- package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
- package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
- package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
- package/skills/migrate/install.sh +68 -0
- package/skills/migrate/package.json +17 -0
- package/skills/migrate/references/phases/enumerate.md +291 -0
- package/skills/migrate/references/phases/extract.md +652 -0
- package/skills/migrate/references/phases/parity.md +275 -0
- package/skills/migrate/references/phases/probe.md +135 -0
- package/skills/migrate/references/phases/queue.md +242 -0
- package/skills/migrate/references/phases/seam.md +416 -0
- package/skills/migrate/references/recipes/README.md +116 -0
- package/skills/migrate/references/recipes/aspnet.md +287 -0
- package/skills/migrate/references/run-ops.md +280 -0
- package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
- package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
- package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
- package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
- package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
- package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
- package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
- package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
- package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
- package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
- package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
- package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
- package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
- package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
- package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
- package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
- package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
- package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
- package/skills/migrate/scripts/census-cmd.ts +109 -0
- package/skills/migrate/scripts/census.ts +342 -0
- package/skills/migrate/scripts/check-cmd.ts +24 -0
- package/skills/migrate/scripts/check.ts +376 -0
- package/skills/migrate/scripts/citations.ts +92 -0
- package/skills/migrate/scripts/config.ts +237 -0
- package/skills/migrate/scripts/ids.ts +31 -0
- package/skills/migrate/scripts/import-cmd.ts +141 -0
- package/skills/migrate/scripts/init-cmd.ts +118 -0
- package/skills/migrate/scripts/leaks.ts +184 -0
- package/skills/migrate/scripts/lock.ts +188 -0
- package/skills/migrate/scripts/paths.ts +103 -0
- package/skills/migrate/scripts/phase-cmd.ts +63 -0
- package/skills/migrate/scripts/phases.ts +113 -0
- package/skills/migrate/scripts/queue-cmd.ts +98 -0
- package/skills/migrate/scripts/queue.ts +258 -0
- package/skills/migrate/scripts/report-cmd.ts +47 -0
- package/skills/migrate/scripts/report.ts +131 -0
- package/skills/migrate/scripts/reset-cmd.ts +120 -0
- package/skills/migrate/scripts/status-cmd.ts +52 -0
- package/skills/migrate/scripts/store.ts +159 -0
- package/skills/migrate/scripts/types.ts +137 -0
- package/skills/migrate/scripts/validate.ts +221 -0
- package/skills/migrate/skill.json +33 -0
- package/skills/migrate/templates/config.toml +27 -0
- package/skills/migrate/templates/queue-item.md +17 -0
- package/skills/migrate/tsconfig.json +18 -0
- package/skills/migrate/uninstall.sh +31 -0
- package/skills/sluice/SKILL.md +82 -0
- package/skills/sluice/references/deep-channel.md +94 -0
- package/skills/sluice/references/finish.md +35 -0
- package/skills/sluice/references/intent.md +29 -0
- package/skills/sluice/references/review.md +42 -0
- package/skills/sluice/references/root-cause.md +38 -0
- package/skills/sluice/references/show-or-say.md +36 -0
- package/skills/sluice/references/test-first.md +35 -0
- package/skills/sluice/references/verify.md +26 -0
- package/skills/sluice/skill.json +32 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { storePaths } from './paths.ts'
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SURFACES: readonly string[] = [
|
|
6
|
+
'routes',
|
|
7
|
+
'tables',
|
|
8
|
+
'jobs',
|
|
9
|
+
'reports',
|
|
10
|
+
'screens',
|
|
11
|
+
'integrations',
|
|
12
|
+
'workflows',
|
|
13
|
+
'settings',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CLOSERS: readonly string[] = [
|
|
17
|
+
'cross-capability-workflow',
|
|
18
|
+
'scope-injection',
|
|
19
|
+
'read-write-symmetry',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
export type Config = {
|
|
23
|
+
source: {
|
|
24
|
+
path: string
|
|
25
|
+
scope: string
|
|
26
|
+
stack: string
|
|
27
|
+
vcs: string
|
|
28
|
+
basis: 'runnable' | 'source-only'
|
|
29
|
+
}
|
|
30
|
+
target: {
|
|
31
|
+
name: string
|
|
32
|
+
stack: string
|
|
33
|
+
parity_test_path: string
|
|
34
|
+
layout: Record<string, string>
|
|
35
|
+
commands: Record<string, string>
|
|
36
|
+
}
|
|
37
|
+
surfaces: string[]
|
|
38
|
+
surfaceSingular: Record<string, string>
|
|
39
|
+
closers: string[]
|
|
40
|
+
handoff: { adapter: string }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ConfigInit = {
|
|
44
|
+
sourcePath: string
|
|
45
|
+
scope: string
|
|
46
|
+
targetName: string
|
|
47
|
+
sourceStack?: string
|
|
48
|
+
targetStack?: string
|
|
49
|
+
vcs?: string
|
|
50
|
+
basis?: 'runnable' | 'source-only'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Values substituted into the TOML template come from operator-typed free text
|
|
54
|
+
// (--scope, --name, and friends), not from anything shaped like TOML syntax. A
|
|
55
|
+
// quote, a backslash, a newline, or a control character must round-trip through
|
|
56
|
+
// loadConfig byte-identical, not be interpreted as TOML escape/string syntax.
|
|
57
|
+
//
|
|
58
|
+
// Bun.TOML.parse (verified directly against Bun 1.3.14) has two quirks that
|
|
59
|
+
// shape this function:
|
|
60
|
+
// 1. The named escapes for tab and form feed decode swapped: writing the
|
|
61
|
+
// tab escape reads back as the form feed code point, and vice versa.
|
|
62
|
+
// Tab is therefore emitted as a literal character rather than escaped
|
|
63
|
+
// (the TOML spec permits a raw, unescaped tab inside a basic string, and
|
|
64
|
+
// Bun reads that back correctly), and form feed is emitted via a
|
|
65
|
+
// four-hex-digit unicode escape instead of its named escape.
|
|
66
|
+
// 2. A four-hex-digit unicode escape only decodes correctly for the C0
|
|
67
|
+
// control codes that coincide with a named escape (backspace, tab,
|
|
68
|
+
// newline, form feed, carriage return) and for the delete character;
|
|
69
|
+
// every other C0 control fails to parse back at all, named or
|
|
70
|
+
// unicode-escaped, so a config.toml written with one of those bytes
|
|
71
|
+
// could never be read again. Those bytes cannot occur from real CLI
|
|
72
|
+
// text (a NUL byte cannot even survive in argv), so rather than
|
|
73
|
+
// silently write a store loadConfig can never reopen, this rejects
|
|
74
|
+
// them with a clear diagnostic instead.
|
|
75
|
+
const TOML_NAMED_ESCAPES: Record<string, string> = {
|
|
76
|
+
'\\': '\\\\',
|
|
77
|
+
'"': '\\"',
|
|
78
|
+
'\b': '\\b',
|
|
79
|
+
'\n': '\\n',
|
|
80
|
+
'\r': '\\r',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const FORM_FEED = String.fromCharCode(0x0c)
|
|
84
|
+
const DEL = String.fromCharCode(0x7f)
|
|
85
|
+
|
|
86
|
+
// Every C0 control other than tab (code point 9) must be escaped in a TOML
|
|
87
|
+
// basic string. Tab, newline, carriage return and backspace are excluded from
|
|
88
|
+
// this set because they are handled above; form feed and delete are excluded
|
|
89
|
+
// because they are handled via a working unicode escape just below. What
|
|
90
|
+
// remains is what Bun's TOML reader cannot represent by any means. Built from
|
|
91
|
+
// explicit code points, rather than a literal character class, so no raw
|
|
92
|
+
// control bytes sit in this source file.
|
|
93
|
+
const UNREPRESENTABLE_CODES = [
|
|
94
|
+
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x0b, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14,
|
|
95
|
+
0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
|
|
96
|
+
]
|
|
97
|
+
const UNREPRESENTABLE = new Set(UNREPRESENTABLE_CODES)
|
|
98
|
+
|
|
99
|
+
function escapeTomlString(value: string, where: string): string {
|
|
100
|
+
let out = ''
|
|
101
|
+
for (const ch of value) {
|
|
102
|
+
const code = ch.codePointAt(0) ?? 0
|
|
103
|
+
if (UNREPRESENTABLE.has(code)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`config.toml: ${where} contains U+${code.toString(16).padStart(4, '0')}, ` +
|
|
106
|
+
'a control character this store cannot represent in config.toml',
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
if (ch === '\t') {
|
|
110
|
+
// Literal tab: legal unescaped in a TOML basic string, and the one
|
|
111
|
+
// representation Bun's reader decodes correctly (see note above).
|
|
112
|
+
out += ch
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (ch === FORM_FEED) {
|
|
116
|
+
out += '\\u000c'
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (ch === DEL) {
|
|
120
|
+
out += '\\u007f'
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
out += TOML_NAMED_ESCAPES[ch] ?? ch
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A plain replaceAll(placeholder, replacement) treats a $-pattern inside the
|
|
129
|
+
// replacement string as a substitution directive (`$&`, `$$`, `$'`, ...), even
|
|
130
|
+
// though the placeholder is a literal string, not a regex. A callback
|
|
131
|
+
// replacement is used verbatim instead, so an escaped value that happens to
|
|
132
|
+
// contain a dollar sign cannot reintroduce corruption after escaping removed
|
|
133
|
+
// the TOML-specific risk.
|
|
134
|
+
function substitute(text: string, placeholder: string, value: string): string {
|
|
135
|
+
return text.replaceAll(placeholder, () => value)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function req(obj: Record<string, unknown> | undefined, key: string, where: string): string {
|
|
139
|
+
const value = obj?.[key]
|
|
140
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
141
|
+
throw new Error(`config.toml: missing or empty ${where}.${key}`)
|
|
142
|
+
}
|
|
143
|
+
return value
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function loadConfig(root: string): Promise<Config> {
|
|
147
|
+
const path = storePaths(root).config
|
|
148
|
+
let text: string
|
|
149
|
+
try {
|
|
150
|
+
text = await readFile(path, 'utf8')
|
|
151
|
+
} catch {
|
|
152
|
+
throw new Error(`config.toml not found at ${path}; run 'migrate init' first`)
|
|
153
|
+
}
|
|
154
|
+
const raw = Bun.TOML.parse(text) as Record<string, Record<string, unknown> | undefined>
|
|
155
|
+
const source = raw.source
|
|
156
|
+
const target = raw.target
|
|
157
|
+
const sourcePath = req(source, 'path', 'source')
|
|
158
|
+
const sourceScope = req(source, 'scope', 'source')
|
|
159
|
+
const sourceStack = req(source, 'stack', 'source')
|
|
160
|
+
const sourceVcs = req(source, 'vcs', 'source')
|
|
161
|
+
const basis = req(source, 'basis', 'source')
|
|
162
|
+
if (basis !== 'runnable' && basis !== 'source-only') {
|
|
163
|
+
throw new Error(`config.toml: source.basis must be runnable or source-only, got ${basis}`)
|
|
164
|
+
}
|
|
165
|
+
const surfacesTable = raw.surfaces
|
|
166
|
+
const declared = surfacesTable?.types
|
|
167
|
+
const singular = (surfacesTable?.singular ?? {}) as Record<string, string>
|
|
168
|
+
const closersTable = raw.closers
|
|
169
|
+
const declaredClosers = closersTable?.set
|
|
170
|
+
const handoffTable = raw.handoff
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
source: {
|
|
174
|
+
path: sourcePath,
|
|
175
|
+
scope: sourceScope,
|
|
176
|
+
stack: sourceStack,
|
|
177
|
+
vcs: sourceVcs,
|
|
178
|
+
basis,
|
|
179
|
+
},
|
|
180
|
+
target: {
|
|
181
|
+
name: req(target, 'name', 'target'),
|
|
182
|
+
stack: req(target, 'stack', 'target'),
|
|
183
|
+
parity_test_path: req(target, 'parity_test_path', 'target'),
|
|
184
|
+
layout: (target?.layout ?? {}) as Record<string, string>,
|
|
185
|
+
commands: (target?.commands ?? {}) as Record<string, string>,
|
|
186
|
+
},
|
|
187
|
+
surfaces: Array.isArray(declared) ? (declared as string[]) : [...DEFAULT_SURFACES],
|
|
188
|
+
surfaceSingular: singular,
|
|
189
|
+
closers: Array.isArray(declaredClosers) ? (declaredClosers as string[]) : [...DEFAULT_CLOSERS],
|
|
190
|
+
handoff: { adapter: (handoffTable?.adapter as string | undefined) ?? 'markdown' },
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function writeConfig(root: string, init: ConfigInit): Promise<void> {
|
|
195
|
+
const templatePath = join(import.meta.dir, '..', 'templates', 'config.toml')
|
|
196
|
+
const template = await readFile(templatePath, 'utf8')
|
|
197
|
+
let rendered = template
|
|
198
|
+
rendered = substitute(
|
|
199
|
+
rendered,
|
|
200
|
+
'{{SOURCE_PATH}}',
|
|
201
|
+
escapeTomlString(init.sourcePath, 'source.path'),
|
|
202
|
+
)
|
|
203
|
+
rendered = substitute(rendered, '{{SCOPE}}', escapeTomlString(init.scope, 'source.scope'))
|
|
204
|
+
rendered = substitute(
|
|
205
|
+
rendered,
|
|
206
|
+
'{{SOURCE_STACK}}',
|
|
207
|
+
escapeTomlString(init.sourceStack ?? 'unknown', 'source.stack'),
|
|
208
|
+
)
|
|
209
|
+
rendered = substitute(rendered, '{{VCS}}', escapeTomlString(init.vcs ?? 'none', 'source.vcs'))
|
|
210
|
+
rendered = substitute(
|
|
211
|
+
rendered,
|
|
212
|
+
'{{BASIS}}',
|
|
213
|
+
escapeTomlString(init.basis ?? 'source-only', 'source.basis'),
|
|
214
|
+
)
|
|
215
|
+
rendered = substitute(
|
|
216
|
+
rendered,
|
|
217
|
+
'{{TARGET_NAME}}',
|
|
218
|
+
escapeTomlString(init.targetName, 'target.name'),
|
|
219
|
+
)
|
|
220
|
+
rendered = substitute(
|
|
221
|
+
rendered,
|
|
222
|
+
'{{TARGET_STACK}}',
|
|
223
|
+
escapeTomlString(init.targetStack ?? 'unknown', 'target.stack'),
|
|
224
|
+
)
|
|
225
|
+
// Deliberately a plain writeFile, not writeAtomically. Routing it through
|
|
226
|
+
// that helper would make writeConfig itself refuse to render a config whose
|
|
227
|
+
// declared source.path contains the store, which sounds right but is the
|
|
228
|
+
// wrong layer for the decision: this function renders a declaration, and
|
|
229
|
+
// whether the declaration is usable is init-cmd.ts's call, made there
|
|
230
|
+
// against all three of init's write targets before anything is created.
|
|
231
|
+
// Putting the refusal here as well would also make the one state those
|
|
232
|
+
// downstream guards exist for -- a config.toml hand-edited after the fact to
|
|
233
|
+
// point at a source containing its own store -- unconstructible through this
|
|
234
|
+
// API, and it is that hand-edit, not init, that reaches them in practice.
|
|
235
|
+
// The containment invariant still holds: init-cmd.ts guards this path.
|
|
236
|
+
await writeFile(storePaths(root).config, rendered)
|
|
237
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
2
|
+
|
|
3
|
+
export function singularOf(surface: string, overrides: Record<string, string>): string {
|
|
4
|
+
const override = overrides[surface]
|
|
5
|
+
if (override) return override
|
|
6
|
+
return surface.endsWith('s') ? surface.slice(0, -1) : surface
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function idPrefixFor(surface: string, overrides: Record<string, string>): string {
|
|
10
|
+
return `${singularOf(surface, overrides)}-`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isValidSlug(s: string): boolean {
|
|
14
|
+
return SLUG.test(s)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateElementId(
|
|
18
|
+
id: string,
|
|
19
|
+
surface: string,
|
|
20
|
+
overrides: Record<string, string>,
|
|
21
|
+
): string | null {
|
|
22
|
+
const prefix = idPrefixFor(surface, overrides)
|
|
23
|
+
if (!id.startsWith(prefix)) {
|
|
24
|
+
return `id ${id} does not start with ${prefix} (surface ${surface})`
|
|
25
|
+
}
|
|
26
|
+
const slug = id.slice(prefix.length)
|
|
27
|
+
if (!isValidSlug(slug)) {
|
|
28
|
+
return `id ${id} has an invalid slug after ${prefix}: want lowercase kebab-case`
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { loadConfig } from './config.ts'
|
|
2
|
+
import { LockError, withStoreLock } from './lock.ts'
|
|
3
|
+
import { assertNotUnderSource, storePaths } from './paths.ts'
|
|
4
|
+
import { isPhase, recordBatch } from './phases.ts'
|
|
5
|
+
import { readJsonFile, readRows, upsertRows, writeRows } from './store.ts'
|
|
6
|
+
import type { Delta, Element, Requirement } from './types.ts'
|
|
7
|
+
import { validateDelta, validateElement, validateRequirement } from './validate.ts'
|
|
8
|
+
|
|
9
|
+
export type ImportKind = 'elements' | 'reqs' | 'deltas'
|
|
10
|
+
|
|
11
|
+
type BatchFile = { batch?: string; phase?: string; rows?: unknown[] }
|
|
12
|
+
|
|
13
|
+
export async function runImport(opts: {
|
|
14
|
+
root: string
|
|
15
|
+
kind: ImportKind
|
|
16
|
+
batchFile: string
|
|
17
|
+
forceUnlock?: boolean
|
|
18
|
+
}): Promise<number> {
|
|
19
|
+
const cfg = await loadConfig(opts.root)
|
|
20
|
+
let raw: unknown
|
|
21
|
+
try {
|
|
22
|
+
raw = await readJsonFile(opts.batchFile)
|
|
23
|
+
} catch (e) {
|
|
24
|
+
// Not shaped like a batch file at all (missing, or not valid JSON): a
|
|
25
|
+
// malformed request, same class as the missing-field check just below.
|
|
26
|
+
process.stderr.write(`import: ${(e as Error).message}\n`)
|
|
27
|
+
return 2
|
|
28
|
+
}
|
|
29
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
30
|
+
process.stderr.write('import: batch file needs {"batch": id, "phase": name, "rows": [...]}\n')
|
|
31
|
+
return 2
|
|
32
|
+
}
|
|
33
|
+
const parsed = raw as BatchFile
|
|
34
|
+
const batchId = parsed.batch
|
|
35
|
+
const phase = parsed.phase
|
|
36
|
+
if (!batchId || !phase || !Array.isArray(parsed.rows)) {
|
|
37
|
+
process.stderr.write('import: batch file needs {"batch": id, "phase": name, "rows": [...]}\n')
|
|
38
|
+
return 2
|
|
39
|
+
}
|
|
40
|
+
if (!isPhase(phase)) {
|
|
41
|
+
process.stderr.write(`import: unknown phase ${phase}\n`)
|
|
42
|
+
return 2
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const errors: string[] = []
|
|
46
|
+
const validated: ({ id: string; batch: string } & Record<string, unknown>)[] = []
|
|
47
|
+
for (const row of parsed.rows) {
|
|
48
|
+
const result =
|
|
49
|
+
opts.kind === 'elements'
|
|
50
|
+
? validateElement(row, cfg)
|
|
51
|
+
: opts.kind === 'reqs'
|
|
52
|
+
? validateRequirement(row, cfg)
|
|
53
|
+
: validateDelta(row, cfg)
|
|
54
|
+
if (result.ok) {
|
|
55
|
+
validated.push({ ...(result.value as object), batch: batchId } as never)
|
|
56
|
+
} else {
|
|
57
|
+
errors.push(...result.errors)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A repeated id within one batch is an authoring error: keeping the last one
|
|
62
|
+
// silently would discard a row nobody was told about. Name every id that
|
|
63
|
+
// recurs so the whole batch is refused under the all-or-nothing rule below.
|
|
64
|
+
const idCounts = new Map<string, number>()
|
|
65
|
+
for (const row of validated) {
|
|
66
|
+
idCounts.set(row.id, (idCounts.get(row.id) ?? 0) + 1)
|
|
67
|
+
}
|
|
68
|
+
for (const [id, count] of idCounts) {
|
|
69
|
+
if (count > 1) errors.push(`batch: id ${id} appears ${count} times in this batch`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// All or nothing. A partially-written batch is a store an agent cannot reason
|
|
73
|
+
// about on resume: the batch id would claim rows that are not all there.
|
|
74
|
+
if (errors.length > 0) {
|
|
75
|
+
for (const e of errors) process.stderr.write(`import: ${e}\n`)
|
|
76
|
+
process.stderr.write(`import: ${errors.length} error(s), nothing written\n`)
|
|
77
|
+
return 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const paths = storePaths(opts.root)
|
|
81
|
+
const target =
|
|
82
|
+
opts.kind === 'elements'
|
|
83
|
+
? paths.elements
|
|
84
|
+
: opts.kind === 'reqs'
|
|
85
|
+
? paths.requirements
|
|
86
|
+
: paths.deltas
|
|
87
|
+
try {
|
|
88
|
+
await assertNotUnderSource(target, cfg.source.path)
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// A store whose configured source.path resolves to include its own
|
|
91
|
+
// target path (e.g. source.path: '.') can never be written to, for any
|
|
92
|
+
// batch content whatsoever: an environment/config problem, invariant
|
|
93
|
+
// across every possible row, not a property of this batch's data. Same
|
|
94
|
+
// usage-error class as "no .migrate store found above the cwd" and
|
|
95
|
+
// "config.toml not found" elsewhere in this CLI, not a batch-content
|
|
96
|
+
// failure (1).
|
|
97
|
+
process.stderr.write(`import: ${(e as Error).message}\n`)
|
|
98
|
+
return 2
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The read, the upsert, the rewrite, and the batch record are one critical
|
|
102
|
+
// section. Splitting them lets a second importer read this one's base,
|
|
103
|
+
// rewrite the file from it, and silently drop every row written in between.
|
|
104
|
+
let merged: { added: number; updated: number }
|
|
105
|
+
try {
|
|
106
|
+
merged = await withStoreLock(
|
|
107
|
+
opts.root,
|
|
108
|
+
async () => {
|
|
109
|
+
const existing = await readRows<Element | Requirement | Delta>(target)
|
|
110
|
+
const result = upsertRows(existing as { id: string }[], validated)
|
|
111
|
+
await writeRows(target, result.rows, cfg.source.path)
|
|
112
|
+
await recordBatch(
|
|
113
|
+
opts.root,
|
|
114
|
+
phase,
|
|
115
|
+
{ id: batchId, count: validated.length },
|
|
116
|
+
cfg.source.path,
|
|
117
|
+
)
|
|
118
|
+
return { added: result.added, updated: result.updated }
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
cmd: 'import',
|
|
122
|
+
...(opts.forceUnlock ? { force: true } : {}),
|
|
123
|
+
onWait: (m) => process.stderr.write(`import: ${m}\n`),
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// A lock failure is neither bad batch content (1) nor a malformed request
|
|
128
|
+
// (2): the request is fine and would succeed on retry, so it gets its own
|
|
129
|
+
// class a caller can branch on.
|
|
130
|
+
if (e instanceof LockError) {
|
|
131
|
+
process.stderr.write(`import: ${e.message}\n`)
|
|
132
|
+
return 3
|
|
133
|
+
}
|
|
134
|
+
throw e
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
process.stdout.write(
|
|
138
|
+
`import ${opts.kind}: ${merged.added} added, ${merged.updated} updated, batch ${batchId}\n`,
|
|
139
|
+
)
|
|
140
|
+
return 0
|
|
141
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
4
|
+
import { writeConfig } from './config.ts'
|
|
5
|
+
import { assertNotUnderSource, storePaths } from './paths.ts'
|
|
6
|
+
|
|
7
|
+
const ENV_IGNORE = '.migrate/.env'
|
|
8
|
+
|
|
9
|
+
// A real ignore entry is a whole line, not any line containing the string as a
|
|
10
|
+
// substring: a comment mentioning the path, or an unrelated deeper path such as
|
|
11
|
+
// foo/.migrate/.env, must not be mistaken for the real entry.
|
|
12
|
+
function gitignoreListsEnvFile(text: string): boolean {
|
|
13
|
+
return text.split('\n').some((line) => {
|
|
14
|
+
const trimmed = line.trim()
|
|
15
|
+
return trimmed.length > 0 && !trimmed.startsWith('#') && trimmed === ENV_IGNORE
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runInit(opts: {
|
|
20
|
+
root: string
|
|
21
|
+
sourcePath: string
|
|
22
|
+
scope: string
|
|
23
|
+
targetName: string
|
|
24
|
+
sourceStack?: string
|
|
25
|
+
targetStack?: string
|
|
26
|
+
vcs?: string
|
|
27
|
+
basis?: string
|
|
28
|
+
}): Promise<number> {
|
|
29
|
+
const sourcePath = isAbsolute(opts.sourcePath) ? opts.sourcePath : resolve(opts.sourcePath)
|
|
30
|
+
if (!existsSync(sourcePath)) {
|
|
31
|
+
process.stderr.write(`init: source path does not exist: ${sourcePath}\n`)
|
|
32
|
+
return 2
|
|
33
|
+
}
|
|
34
|
+
if (!statSync(sourcePath).isDirectory()) {
|
|
35
|
+
process.stderr.write(`init: source path is not a directory: ${sourcePath}\n`)
|
|
36
|
+
return 2
|
|
37
|
+
}
|
|
38
|
+
const basis = opts.basis ?? 'source-only'
|
|
39
|
+
if (basis !== 'runnable' && basis !== 'source-only') {
|
|
40
|
+
process.stderr.write(`init: --basis must be runnable or source-only, got ${basis}\n`)
|
|
41
|
+
return 2
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const paths = storePaths(opts.root)
|
|
45
|
+
const gitignore = join(opts.root, '.gitignore')
|
|
46
|
+
|
|
47
|
+
// Every other command reaches the source path through a store that already
|
|
48
|
+
// exists, so its writers inherit the containment guard from the helper they
|
|
49
|
+
// write through. `init` is the command that creates that store, and it had
|
|
50
|
+
// no guard at all: pointing --source at a directory containing the target
|
|
51
|
+
// produced a config.toml, a queue directory and a .gitignore edit inside the
|
|
52
|
+
// read-only source tree, after which every later command exited 2 on the
|
|
53
|
+
// guard those writers do have. The user was left with a store nothing could
|
|
54
|
+
// write to, three shipped documents having promised this was impossible.
|
|
55
|
+
//
|
|
56
|
+
// All three write targets are checked here, before anything is created, so a
|
|
57
|
+
// refusal leaves the tree exactly as it was found rather than half-built.
|
|
58
|
+
// Reported and returned rather than thrown, matching how import-cmd.ts and
|
|
59
|
+
// census-cmd.ts already classify this same refusal: a request that could
|
|
60
|
+
// never be serviced as posed is a usage error (2), not a content failure.
|
|
61
|
+
// The check runs ahead of the existing-config check below because a config
|
|
62
|
+
// already sitting inside the source tree is itself the wreckage of this bug,
|
|
63
|
+
// and "that store can never work" is the more useful thing to say about it
|
|
64
|
+
// than "that file is already there".
|
|
65
|
+
for (const target of [paths.config, paths.queueDir, gitignore]) {
|
|
66
|
+
try {
|
|
67
|
+
await assertNotUnderSource(target, sourcePath)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
process.stderr.write(`init: ${(e as Error).message}\n`)
|
|
70
|
+
return 2
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (existsSync(paths.config)) {
|
|
75
|
+
process.stderr.write(`init: ${paths.config} already exists; edit it or remove it first\n`)
|
|
76
|
+
return 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await mkdir(paths.queueDir, { recursive: true })
|
|
80
|
+
await writeConfig(opts.root, {
|
|
81
|
+
sourcePath,
|
|
82
|
+
scope: opts.scope,
|
|
83
|
+
targetName: opts.targetName,
|
|
84
|
+
...(opts.sourceStack ? { sourceStack: opts.sourceStack } : {}),
|
|
85
|
+
...(opts.targetStack ? { targetStack: opts.targetStack } : {}),
|
|
86
|
+
vcs: opts.vcs ?? (existsSync(join(sourcePath, '.git')) ? 'git' : 'none'),
|
|
87
|
+
basis,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
process.stdout.write(`init: wrote ${paths.config}\n`)
|
|
91
|
+
|
|
92
|
+
// The append branch used to be the whole of this: a target repo with no
|
|
93
|
+
// .gitignore of its own got no ignore entry, so the first `git add -A` of
|
|
94
|
+
// the run staged .migrate/.env -- the one file in the store most likely to
|
|
95
|
+
// hold credentials, in the one place (a fresh target, first commit) where
|
|
96
|
+
// nobody has looked at the ignore rules yet. The `leaks` gate that would
|
|
97
|
+
// catch the committed result is opt-in, so nothing else stands behind this.
|
|
98
|
+
// Creating the file closes that.
|
|
99
|
+
//
|
|
100
|
+
// Both branches say what they did on stdout. init writing or editing a file
|
|
101
|
+
// outside .migrate/ that the caller did not name is worth one line either
|
|
102
|
+
// way; a silent edit to a .gitignore the caller already maintains is no more
|
|
103
|
+
// discoverable than a silent creation.
|
|
104
|
+
if (existsSync(gitignore)) {
|
|
105
|
+
const text = await readFile(gitignore, 'utf8')
|
|
106
|
+
if (!gitignoreListsEnvFile(text)) {
|
|
107
|
+
const prefix = text.endsWith('\n') || text.length === 0 ? '' : '\n'
|
|
108
|
+
await appendFile(gitignore, `${prefix}${ENV_IGNORE}\n`)
|
|
109
|
+
process.stdout.write(`init: appended ${ENV_IGNORE} to ${gitignore}\n`)
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
await writeFile(gitignore, `${ENV_IGNORE}\n`)
|
|
113
|
+
process.stdout.write(`init: created ${gitignore} with ${ENV_IGNORE}\n`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
process.stdout.write('next: run the enumerate phase, then `migrate check`\n')
|
|
117
|
+
return 0
|
|
118
|
+
}
|