@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.
Files changed (148) hide show
  1. package/README.md +18 -2
  2. package/dist/cli/index.js +105 -28
  3. package/package.json +1 -1
  4. package/skills/index.json +14 -2
  5. package/skills/magpie/README.md +2 -1
  6. package/skills/magpie/SKILL.md +138 -45
  7. package/skills/magpie/bin/magpie.ts +43 -0
  8. package/skills/magpie/fixtures/example-pr/brief.json +18 -0
  9. package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
  10. package/skills/magpie/fixtures/fake-gh.sh +14 -0
  11. package/skills/magpie/package.json +1 -1
  12. package/skills/magpie/references/peer-review.md +7 -2
  13. package/skills/magpie/references/scout.md +90 -0
  14. package/skills/magpie/references/specialists.md +117 -6
  15. package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
  16. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
  17. package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
  18. package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
  19. package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
  20. package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
  22. package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
  23. package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
  24. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
  25. package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
  26. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
  27. package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
  28. package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
  29. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
  30. package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
  31. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
  32. package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
  33. package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
  34. package/skills/magpie/scripts/diff-chunks.ts +28 -0
  35. package/skills/magpie/scripts/findings-files.ts +32 -0
  36. package/skills/magpie/scripts/gh.ts +68 -14
  37. package/skills/magpie/scripts/git-diff.ts +111 -0
  38. package/skills/magpie/scripts/path-filter.ts +9 -5
  39. package/skills/magpie/scripts/preview-cmd.ts +11 -1
  40. package/skills/magpie/scripts/refresh.ts +32 -3
  41. package/skills/magpie/scripts/render-cmd.ts +35 -11
  42. package/skills/magpie/scripts/render-findings.ts +78 -2
  43. package/skills/magpie/scripts/render-progress.ts +6 -1
  44. package/skills/magpie/scripts/setup-cmd.ts +38 -1
  45. package/skills/magpie/scripts/shard.ts +171 -0
  46. package/skills/magpie/scripts/status-cmd.ts +7 -4
  47. package/skills/magpie/scripts/types.ts +50 -0
  48. package/skills/magpie/skill.json +2 -2
  49. package/skills/magpie/templates/styles.css +66 -0
  50. package/skills/migrate/README.md +194 -0
  51. package/skills/migrate/SKILL.md +197 -0
  52. package/skills/migrate/bin/migrate +15 -0
  53. package/skills/migrate/bin/migrate.ts +309 -0
  54. package/skills/migrate/biome.json +35 -0
  55. package/skills/migrate/bun.lock +24 -0
  56. package/skills/migrate/docs/architecture.md +294 -0
  57. package/skills/migrate/docs/reference.md +590 -0
  58. package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
  59. package/skills/migrate/fixtures/tiny-express/app.js +29 -0
  60. package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
  61. package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
  62. package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
  63. package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
  64. package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
  65. package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
  66. package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
  67. package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
  68. package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
  69. package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
  70. package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
  71. package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
  72. package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
  73. package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
  74. package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
  75. package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
  76. package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
  77. package/skills/migrate/install.sh +68 -0
  78. package/skills/migrate/package.json +17 -0
  79. package/skills/migrate/references/phases/enumerate.md +291 -0
  80. package/skills/migrate/references/phases/extract.md +652 -0
  81. package/skills/migrate/references/phases/parity.md +275 -0
  82. package/skills/migrate/references/phases/probe.md +135 -0
  83. package/skills/migrate/references/phases/queue.md +242 -0
  84. package/skills/migrate/references/phases/seam.md +416 -0
  85. package/skills/migrate/references/recipes/README.md +116 -0
  86. package/skills/migrate/references/recipes/aspnet.md +287 -0
  87. package/skills/migrate/references/run-ops.md +280 -0
  88. package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
  89. package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
  90. package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
  91. package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
  92. package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
  93. package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
  94. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
  95. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
  96. package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
  97. package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
  98. package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
  99. package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
  100. package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
  101. package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
  102. package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
  103. package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
  104. package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
  105. package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
  106. package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
  107. package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
  108. package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
  109. package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
  110. package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
  111. package/skills/migrate/scripts/census-cmd.ts +109 -0
  112. package/skills/migrate/scripts/census.ts +342 -0
  113. package/skills/migrate/scripts/check-cmd.ts +24 -0
  114. package/skills/migrate/scripts/check.ts +376 -0
  115. package/skills/migrate/scripts/citations.ts +92 -0
  116. package/skills/migrate/scripts/config.ts +237 -0
  117. package/skills/migrate/scripts/ids.ts +31 -0
  118. package/skills/migrate/scripts/import-cmd.ts +141 -0
  119. package/skills/migrate/scripts/init-cmd.ts +118 -0
  120. package/skills/migrate/scripts/leaks.ts +184 -0
  121. package/skills/migrate/scripts/lock.ts +188 -0
  122. package/skills/migrate/scripts/paths.ts +103 -0
  123. package/skills/migrate/scripts/phase-cmd.ts +63 -0
  124. package/skills/migrate/scripts/phases.ts +113 -0
  125. package/skills/migrate/scripts/queue-cmd.ts +98 -0
  126. package/skills/migrate/scripts/queue.ts +258 -0
  127. package/skills/migrate/scripts/report-cmd.ts +47 -0
  128. package/skills/migrate/scripts/report.ts +131 -0
  129. package/skills/migrate/scripts/reset-cmd.ts +120 -0
  130. package/skills/migrate/scripts/status-cmd.ts +52 -0
  131. package/skills/migrate/scripts/store.ts +159 -0
  132. package/skills/migrate/scripts/types.ts +137 -0
  133. package/skills/migrate/scripts/validate.ts +221 -0
  134. package/skills/migrate/skill.json +33 -0
  135. package/skills/migrate/templates/config.toml +27 -0
  136. package/skills/migrate/templates/queue-item.md +17 -0
  137. package/skills/migrate/tsconfig.json +18 -0
  138. package/skills/migrate/uninstall.sh +31 -0
  139. package/skills/sluice/SKILL.md +82 -0
  140. package/skills/sluice/references/deep-channel.md +94 -0
  141. package/skills/sluice/references/finish.md +35 -0
  142. package/skills/sluice/references/intent.md +29 -0
  143. package/skills/sluice/references/review.md +42 -0
  144. package/skills/sluice/references/root-cause.md +38 -0
  145. package/skills/sluice/references/show-or-say.md +36 -0
  146. package/skills/sluice/references/test-first.md +35 -0
  147. package/skills/sluice/references/verify.md +26 -0
  148. 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
+ }