@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,192 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { loadConfig } from '../config.ts'
|
|
7
|
+
import { runInit } from '../init-cmd.ts'
|
|
8
|
+
import { storePaths } from '../paths.ts'
|
|
9
|
+
|
|
10
|
+
async function captureStdout(fn: () => Promise<number>): Promise<{ code: number; text: string }> {
|
|
11
|
+
const out: string[] = []
|
|
12
|
+
const original = process.stdout.write.bind(process.stdout)
|
|
13
|
+
process.stdout.write = ((s: string) => {
|
|
14
|
+
out.push(s)
|
|
15
|
+
return true
|
|
16
|
+
}) as typeof process.stdout.write
|
|
17
|
+
try {
|
|
18
|
+
const code = await fn()
|
|
19
|
+
return { code, text: out.join('') }
|
|
20
|
+
} finally {
|
|
21
|
+
process.stdout.write = original
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let root: string
|
|
26
|
+
let source: string
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-init-'))
|
|
30
|
+
source = join(root, 'legacy')
|
|
31
|
+
await mkdir(source, { recursive: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await rm(root, { recursive: true, force: true })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const BASE = () => ({ root, sourcePath: source, scope: 'billing', targetName: 'newapp' })
|
|
39
|
+
|
|
40
|
+
test('init creates the store and a loadable config', async () => {
|
|
41
|
+
expect(await runInit(BASE())).toBe(0)
|
|
42
|
+
expect(existsSync(storePaths(root).queueDir)).toBe(true)
|
|
43
|
+
const cfg = await loadConfig(root)
|
|
44
|
+
expect(cfg.source.scope).toBe('billing')
|
|
45
|
+
expect(cfg.target.name).toBe('newapp')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('init refuses to overwrite an existing config', async () => {
|
|
49
|
+
await runInit(BASE())
|
|
50
|
+
expect(await runInit(BASE())).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('init refuses a source path that does not exist', async () => {
|
|
54
|
+
expect(await runInit({ ...BASE(), sourcePath: join(root, 'nope') })).toBe(2)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('init appends the env file to an existing .gitignore exactly once', async () => {
|
|
58
|
+
await writeFile(join(root, '.gitignore'), 'node_modules/\n')
|
|
59
|
+
await runInit(BASE())
|
|
60
|
+
const text = await readFile(join(root, '.gitignore'), 'utf8')
|
|
61
|
+
expect(text).toContain('.migrate/.env')
|
|
62
|
+
expect(text.match(/\.migrate\/\.env/g)).toHaveLength(1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('init records an explicit basis', async () => {
|
|
66
|
+
await runInit({ ...BASE(), basis: 'runnable' })
|
|
67
|
+
expect((await loadConfig(root)).source.basis).toBe('runnable')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('init appends the env file to a .gitignore that lacks a trailing newline', async () => {
|
|
71
|
+
await writeFile(join(root, '.gitignore'), 'node_modules/')
|
|
72
|
+
await runInit(BASE())
|
|
73
|
+
const text = await readFile(join(root, '.gitignore'), 'utf8')
|
|
74
|
+
expect(text).toBe('node_modules/\n.migrate/.env\n')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('init detects vcs as git when the source path contains a .git directory', async () => {
|
|
78
|
+
await mkdir(join(source, '.git'), { recursive: true })
|
|
79
|
+
await runInit(BASE())
|
|
80
|
+
expect((await loadConfig(root)).source.vcs).toBe('git')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('init detects vcs as none when the source path has no .git directory', async () => {
|
|
84
|
+
await runInit(BASE())
|
|
85
|
+
expect((await loadConfig(root)).source.vcs).toBe('none')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('init refuses a source path that is a file, not a directory', async () => {
|
|
89
|
+
const filePath = join(root, 'not-a-dir.txt')
|
|
90
|
+
await writeFile(filePath, 'hi')
|
|
91
|
+
expect(await runInit({ ...BASE(), sourcePath: filePath })).toBe(2)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('init does not mistake an unrelated deeper path for a real .gitignore entry', async () => {
|
|
95
|
+
await writeFile(join(root, '.gitignore'), 'node_modules/\nfoo/.migrate/.env\n')
|
|
96
|
+
await runInit(BASE())
|
|
97
|
+
const text = await readFile(join(root, '.gitignore'), 'utf8')
|
|
98
|
+
const lines = text.split('\n').map((l) => l.trim())
|
|
99
|
+
expect(lines).toContain('.migrate/.env')
|
|
100
|
+
expect(lines.filter((l) => l === '.migrate/.env')).toHaveLength(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('init does not mistake a commented-out mention for a real .gitignore entry', async () => {
|
|
104
|
+
await writeFile(join(root, '.gitignore'), 'node_modules/\n# .migrate/.env (handled elsewhere)\n')
|
|
105
|
+
await runInit(BASE())
|
|
106
|
+
const text = await readFile(join(root, '.gitignore'), 'utf8')
|
|
107
|
+
const lines = text.split('\n').map((l) => l.trim())
|
|
108
|
+
expect(lines).toContain('.migrate/.env')
|
|
109
|
+
expect(lines.filter((l) => l === '.migrate/.env')).toHaveLength(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Final-review finding, Important 1: init was the one writer with no
|
|
113
|
+
// containment guard. `migrate init --source .` inside a source tree created
|
|
114
|
+
// config.toml, made queue/, and appended to the source's own .gitignore; every
|
|
115
|
+
// later command then exited 2 on the guard those writers do have, leaving a
|
|
116
|
+
// store nothing could write to. SKILL.md, docs/reference.md and
|
|
117
|
+
// docs/architecture.md all claimed this was impossible.
|
|
118
|
+
|
|
119
|
+
test('init refuses to build a store inside the read-only source tree, writing nothing', async () => {
|
|
120
|
+
const result = await runInit({ ...BASE(), sourcePath: root })
|
|
121
|
+
expect(result).toBe(2)
|
|
122
|
+
expect(existsSync(storePaths(root).dir)).toBe(false)
|
|
123
|
+
expect(existsSync(join(root, '.gitignore'))).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// The three write targets init checks share a parent for an ordinary target
|
|
127
|
+
// root, so a plain path comparison can never tell them apart. A symlink can:
|
|
128
|
+
// here .migrate resolves into the source tree while the target root itself is
|
|
129
|
+
// nowhere near it, so only the store-path legs of the check fire. The guard
|
|
130
|
+
// resolves real paths, which is what makes this reachable at all.
|
|
131
|
+
test('init refuses when only the store directory resolves into the source tree', async () => {
|
|
132
|
+
const stash = join(source, 'stash')
|
|
133
|
+
await mkdir(stash, { recursive: true })
|
|
134
|
+
await symlink(stash, storePaths(root).dir)
|
|
135
|
+
expect(await runInit(BASE())).toBe(2)
|
|
136
|
+
expect(existsSync(join(stash, 'config.toml'))).toBe(false)
|
|
137
|
+
expect(existsSync(join(stash, 'queue'))).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// The mirror image, and the leg the store-path checks alone would miss: the
|
|
141
|
+
// store is fine, but the target's .gitignore is a symlink into the source, so
|
|
142
|
+
// the append would edit a file in the read-only checkout. Refused before
|
|
143
|
+
// anything is created, so the planted file is byte-identical afterwards.
|
|
144
|
+
test('init refuses when only the .gitignore resolves into the source tree', async () => {
|
|
145
|
+
const planted = join(source, '.gitignore')
|
|
146
|
+
await writeFile(planted, 'node_modules/\n')
|
|
147
|
+
await symlink(planted, join(root, '.gitignore'))
|
|
148
|
+
expect(await runInit(BASE())).toBe(2)
|
|
149
|
+
expect(await readFile(planted, 'utf8')).toBe('node_modules/\n')
|
|
150
|
+
expect(existsSync(storePaths(root).dir)).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Final-review finding, Important 2: the append was guarded by
|
|
154
|
+
// `if (existsSync(gitignore))`, so a target repo with no .gitignore of its own
|
|
155
|
+
// got no ignore entry at all and `git add -A` staged .migrate/.env on the
|
|
156
|
+
// first commit of the run. The `leaks` gate that would catch the committed
|
|
157
|
+
// result is opt-in, so nothing else stood behind this.
|
|
158
|
+
|
|
159
|
+
test('init creates a .gitignore listing the env file when the target has none, and says so', async () => {
|
|
160
|
+
const { code, text } = await captureStdout(() => runInit(BASE()))
|
|
161
|
+
expect(code).toBe(0)
|
|
162
|
+
const gitignore = join(root, '.gitignore')
|
|
163
|
+
expect(await readFile(gitignore, 'utf8')).toBe('.migrate/.env\n')
|
|
164
|
+
expect(text).toContain(`init: created ${gitignore} with .migrate/.env`)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('init reports the append when the target already has a .gitignore', async () => {
|
|
168
|
+
const gitignore = join(root, '.gitignore')
|
|
169
|
+
await writeFile(gitignore, 'node_modules/\n')
|
|
170
|
+
const { code, text } = await captureStdout(() => runInit(BASE()))
|
|
171
|
+
expect(code).toBe(0)
|
|
172
|
+
expect(text).toContain(`init: appended .migrate/.env to ${gitignore}`)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// The claim the two tests above exist to make is not really about file
|
|
176
|
+
// content, it is that git will not stage the env file. Asserted against real
|
|
177
|
+
// git rather than by reading the ignore text, since the ignore text being
|
|
178
|
+
// right and git still staging the file is precisely the failure that shipped.
|
|
179
|
+
test('git does not stage .migrate/.env after init, on a target that had no .gitignore', async () => {
|
|
180
|
+
await Bun.spawn(['git', 'init', '-q'], { cwd: root, stdout: 'ignore', stderr: 'ignore' }).exited
|
|
181
|
+
expect(await runInit(BASE())).toBe(0)
|
|
182
|
+
await writeFile(storePaths(root).env, 'API_TOKEN=super-secret\n')
|
|
183
|
+
const proc = Bun.spawn(['git', 'check-ignore', '.migrate/.env'], {
|
|
184
|
+
cwd: root,
|
|
185
|
+
stdout: 'pipe',
|
|
186
|
+
stderr: 'ignore',
|
|
187
|
+
})
|
|
188
|
+
const listed = await new Response(proc.stdout).text()
|
|
189
|
+
await proc.exited
|
|
190
|
+
expect(proc.exitCode).toBe(0)
|
|
191
|
+
expect(listed.trim()).toBe('.migrate/.env')
|
|
192
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { parseEnv, scanLeaks, secretValues } from '../leaks.ts'
|
|
6
|
+
|
|
7
|
+
let root: string
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-leaks-'))
|
|
11
|
+
await mkdir(join(root, '.migrate', 'queue'), { recursive: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(root, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
async function git(args: string[], cwd: string): Promise<void> {
|
|
19
|
+
const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'ignore', stderr: 'ignore' })
|
|
20
|
+
const code = await proc.exited
|
|
21
|
+
if (code !== 0) throw new Error(`git ${args.join(' ')} failed with exit code ${code}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function initRepo(cwd: string): Promise<void> {
|
|
25
|
+
await git(['init', '-q'], cwd)
|
|
26
|
+
await git(['config', 'user.email', 'test@example.com'], cwd)
|
|
27
|
+
await git(['config', 'user.name', 'Test'], cwd)
|
|
28
|
+
await git(['config', 'commit.gpgsign', 'false'], cwd)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('parseEnv handles quotes, export and comments', () => {
|
|
32
|
+
const env = parseEnv('# a comment\nexport A="one two"\nB=plain\nC=\n')
|
|
33
|
+
expect(env.A).toBe('one two')
|
|
34
|
+
expect(env.B).toBe('plain')
|
|
35
|
+
expect(env.C).toBe('')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('secretValues drops empties, short values and urls', () => {
|
|
39
|
+
const values = secretValues({
|
|
40
|
+
MIGRATE_SOURCE_URL: 'https://legacy.example.com',
|
|
41
|
+
MIGRATE_SOURCE_PASSWORD: 'hunter2-longer-secret',
|
|
42
|
+
MIGRATE_SOURCE_READONLY: 'true',
|
|
43
|
+
SHORT: 'abc',
|
|
44
|
+
EMPTY: '',
|
|
45
|
+
})
|
|
46
|
+
expect(values).toEqual(['hunter2-longer-secret'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('scanLeaks finds a secret pasted into an artifact and names the variable', async () => {
|
|
50
|
+
await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
|
|
51
|
+
await writeFile(
|
|
52
|
+
join(root, '.migrate', 'queue', 'q-a.md'),
|
|
53
|
+
'logged in with hunter2-longer-secret\n',
|
|
54
|
+
)
|
|
55
|
+
const violations = await scanLeaks({ root })
|
|
56
|
+
expect(violations).toHaveLength(1)
|
|
57
|
+
expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD')
|
|
58
|
+
expect(violations[0]?.message).toContain('q-a.md')
|
|
59
|
+
expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('scanLeaks passes on a clean store', async () => {
|
|
63
|
+
await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
|
|
64
|
+
await writeFile(join(root, '.migrate', 'queue', 'q-a.md'), 'observed at legacy.example.com\n')
|
|
65
|
+
expect(await scanLeaks({ root })).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('scanLeaks is a no-op with no .env file', async () => {
|
|
69
|
+
expect(await scanLeaks({ root })).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('scanLeaks reports an unreadable artifact as a violation instead of skipping it', async () => {
|
|
73
|
+
await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
|
|
74
|
+
const target = join(root, '.migrate', 'queue', 'q-a.md')
|
|
75
|
+
await writeFile(target, 'nothing secret here\n')
|
|
76
|
+
await chmod(target, 0o000)
|
|
77
|
+
const violations = await scanLeaks({ root })
|
|
78
|
+
expect(violations).toHaveLength(1)
|
|
79
|
+
expect(violations[0]?.gate).toBe('leaks')
|
|
80
|
+
expect(violations[0]?.message).toContain('q-a.md')
|
|
81
|
+
expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('scanLeaks reports one violation per file for a value shared by two variables, naming both', async () => {
|
|
85
|
+
await writeFile(
|
|
86
|
+
join(root, '.migrate', '.env'),
|
|
87
|
+
'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\nMIGRATE_SOURCE_PASSWORD_RO=hunter2-longer-secret\n',
|
|
88
|
+
)
|
|
89
|
+
await writeFile(
|
|
90
|
+
join(root, '.migrate', 'queue', 'q-a.md'),
|
|
91
|
+
'logged in with hunter2-longer-secret\n',
|
|
92
|
+
)
|
|
93
|
+
const violations = await scanLeaks({ root })
|
|
94
|
+
expect(violations).toHaveLength(1)
|
|
95
|
+
expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD_RO')
|
|
96
|
+
expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD')
|
|
97
|
+
expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('scanLeaks never puts a secret value on the git subprocess argument list', async () => {
|
|
101
|
+
await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
|
|
102
|
+
await mkdir(join(root, '.git'))
|
|
103
|
+
const capturePath = join(root, 'argv-capture.log')
|
|
104
|
+
const stubPath = join(root, 'git-stub.sh')
|
|
105
|
+
await writeFile(stubPath, `#!/bin/sh\necho "$@" >> ${JSON.stringify(capturePath)}\n`)
|
|
106
|
+
await chmod(stubPath, 0o755)
|
|
107
|
+
|
|
108
|
+
await scanLeaks({ root, gitBin: stubPath })
|
|
109
|
+
|
|
110
|
+
const captured = await readFile(capturePath, 'utf8').catch(() => '')
|
|
111
|
+
expect(captured.length).toBeGreaterThan(0)
|
|
112
|
+
expect(captured).not.toContain('hunter2-longer-secret')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('scanLeaks finds a secret spanning a chunk boundary in the git stub output', async () => {
|
|
116
|
+
// The stub writes the secret in two separate stdout writes with a pause in
|
|
117
|
+
// between, so the parent's stream reads them as two separate chunks with
|
|
118
|
+
// the secret split across the boundary. This exercises the carry-over
|
|
119
|
+
// logic in findInStream rather than relying on how a real git process
|
|
120
|
+
// happens to buffer its output.
|
|
121
|
+
const secret = 'hunter2-longer-secret'
|
|
122
|
+
await writeFile(join(root, '.migrate', '.env'), `MIGRATE_SOURCE_PASSWORD=${secret}\n`)
|
|
123
|
+
await mkdir(join(root, '.git'))
|
|
124
|
+
const stubPath = join(root, 'git-stub-split.ts')
|
|
125
|
+
const mid = Math.floor(secret.length / 2)
|
|
126
|
+
await writeFile(
|
|
127
|
+
stubPath,
|
|
128
|
+
[
|
|
129
|
+
'#!/usr/bin/env bun',
|
|
130
|
+
`process.stdout.write(${JSON.stringify(secret.slice(0, mid))})`,
|
|
131
|
+
'await new Promise((resolve) => setTimeout(resolve, 50))',
|
|
132
|
+
`process.stdout.write(${JSON.stringify(secret.slice(mid))})`,
|
|
133
|
+
'',
|
|
134
|
+
].join('\n'),
|
|
135
|
+
)
|
|
136
|
+
await chmod(stubPath, 0o755)
|
|
137
|
+
|
|
138
|
+
const violations = await scanLeaks({ root, gitBin: stubPath })
|
|
139
|
+
expect(violations).toHaveLength(1)
|
|
140
|
+
expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD')
|
|
141
|
+
expect(violations[0]?.message).toContain('git history')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('scanLeaks finds a secret that exists only in git history, not in the working tree', async () => {
|
|
145
|
+
await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
|
|
146
|
+
await initRepo(root)
|
|
147
|
+
const notes = join(root, 'notes.txt')
|
|
148
|
+
await writeFile(notes, 'logged in with hunter2-longer-secret\n')
|
|
149
|
+
await git(['add', 'notes.txt'], root)
|
|
150
|
+
await git(['commit', '-q', '-m', 'add notes'], root)
|
|
151
|
+
await writeFile(notes, 'logged in fine\n')
|
|
152
|
+
await git(['add', 'notes.txt'], root)
|
|
153
|
+
await git(['commit', '-q', '-m', 'redact notes'], root)
|
|
154
|
+
|
|
155
|
+
const violations = await scanLeaks({ root })
|
|
156
|
+
expect(violations).toHaveLength(1)
|
|
157
|
+
expect(violations[0]?.gate).toBe('leaks')
|
|
158
|
+
expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD')
|
|
159
|
+
expect(violations[0]?.message).toContain('git history')
|
|
160
|
+
expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('scanLeaks does not attempt to run git when there is no .git directory', async () => {
|
|
164
|
+
await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
|
|
165
|
+
const violations = await scanLeaks({ root, gitBin: join(root, 'no-such-git-binary') })
|
|
166
|
+
expect(violations).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('scanLeaks reports a violation when git cannot be run, instead of crashing', async () => {
|
|
170
|
+
await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
|
|
171
|
+
await mkdir(join(root, '.git'))
|
|
172
|
+
const violations = await scanLeaks({ root, gitBin: join(root, 'no-such-git-binary') })
|
|
173
|
+
expect(violations).toHaveLength(1)
|
|
174
|
+
expect(violations[0]?.gate).toBe('leaks')
|
|
175
|
+
expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
|
|
176
|
+
})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, unlinkSync } from 'node:fs'
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { LockError, lockPath, processIsAlive, withStoreLock } from '../lock.ts'
|
|
7
|
+
|
|
8
|
+
let root: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-lock-'))
|
|
12
|
+
await mkdir(join(root, '.migrate'), { recursive: true })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(root, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('runs the body, returns its value, and removes the lock afterwards', async () => {
|
|
20
|
+
const result = await withStoreLock(root, async () => 'done', { cmd: 'import' })
|
|
21
|
+
expect(result).toBe('done')
|
|
22
|
+
expect(existsSync(lockPath(root))).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('the lock file names the holder while the body runs', async () => {
|
|
26
|
+
let holder: { pid: number; cmd: string; startedAt: string } | null = null
|
|
27
|
+
await withStoreLock(
|
|
28
|
+
root,
|
|
29
|
+
async () => {
|
|
30
|
+
holder = JSON.parse(await readFile(lockPath(root), 'utf8'))
|
|
31
|
+
},
|
|
32
|
+
{ cmd: 'census' },
|
|
33
|
+
)
|
|
34
|
+
expect(holder).not.toBeNull()
|
|
35
|
+
expect((holder as unknown as { pid: number }).pid).toBe(process.pid)
|
|
36
|
+
expect((holder as unknown as { cmd: string }).cmd).toBe('census')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('releases the lock when the body throws, and propagates the error', async () => {
|
|
40
|
+
const boom = withStoreLock(
|
|
41
|
+
root,
|
|
42
|
+
async () => {
|
|
43
|
+
throw new Error('body failed')
|
|
44
|
+
},
|
|
45
|
+
{ cmd: 'import' },
|
|
46
|
+
)
|
|
47
|
+
await expect(boom).rejects.toThrow('body failed')
|
|
48
|
+
expect(existsSync(lockPath(root))).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('refuses immediately when the holder pid is not running, naming --force-unlock', async () => {
|
|
52
|
+
await writeFile(
|
|
53
|
+
lockPath(root),
|
|
54
|
+
JSON.stringify({ pid: 999999, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
|
|
55
|
+
)
|
|
56
|
+
const attempt = withStoreLock(root, async () => 'unreachable', {
|
|
57
|
+
cmd: 'import',
|
|
58
|
+
alive: () => false,
|
|
59
|
+
timeoutMs: 30_000,
|
|
60
|
+
})
|
|
61
|
+
await expect(attempt).rejects.toThrow(/--force-unlock/)
|
|
62
|
+
await attempt.catch((e) => {
|
|
63
|
+
expect(e).toBeInstanceOf(LockError)
|
|
64
|
+
expect((e as LockError).kind).toBe('stale')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Regression: readHolder and alive() are separated by an await boundary, so
|
|
69
|
+
// a holder that finishes, unlinks the lock, and exits in that gap used to
|
|
70
|
+
// make alive() return false for a pid that no longer matters, reported as a
|
|
71
|
+
// stale lock even though the lock was actually free. The alive hook's own
|
|
72
|
+
// side effect stands in for that race deterministically: it removes the lock
|
|
73
|
+
// file at the exact moment withStoreLock asks whether the holder it just read
|
|
74
|
+
// is still alive, so the confirming re-read must see the lock gone and retry
|
|
75
|
+
// rather than concluding pid 4242 is a dead holder.
|
|
76
|
+
test('a holder that releases the lock in the gap between the read and the liveness check is retried, not reported stale', async () => {
|
|
77
|
+
await writeFile(
|
|
78
|
+
lockPath(root),
|
|
79
|
+
JSON.stringify({ pid: 4242, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
|
|
80
|
+
)
|
|
81
|
+
let calls = 0
|
|
82
|
+
const result = await withStoreLock(root, async () => 'ran', {
|
|
83
|
+
cmd: 'import',
|
|
84
|
+
alive: (pid) => {
|
|
85
|
+
calls += 1
|
|
86
|
+
if (pid === 4242) unlinkSync(lockPath(root))
|
|
87
|
+
return false
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
expect(result).toBe('ran')
|
|
91
|
+
// Called exactly once: the confirming re-read found the lock gone and the
|
|
92
|
+
// retry went straight to acquiring it, never asking alive() about a second
|
|
93
|
+
// holder.
|
|
94
|
+
expect(calls).toBe(1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('times out while a live holder keeps the lock, naming the holder', async () => {
|
|
98
|
+
await writeFile(
|
|
99
|
+
lockPath(root),
|
|
100
|
+
JSON.stringify({ pid: 4412, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
|
|
101
|
+
)
|
|
102
|
+
const waits: string[] = []
|
|
103
|
+
const attempt = withStoreLock(root, async () => 'unreachable', {
|
|
104
|
+
cmd: 'import',
|
|
105
|
+
alive: () => true,
|
|
106
|
+
timeoutMs: 120,
|
|
107
|
+
onWait: (m) => waits.push(m),
|
|
108
|
+
})
|
|
109
|
+
await attempt.catch((e) => {
|
|
110
|
+
expect(e).toBeInstanceOf(LockError)
|
|
111
|
+
expect((e as LockError).kind).toBe('timeout')
|
|
112
|
+
expect((e as LockError).message).toContain('4412')
|
|
113
|
+
})
|
|
114
|
+
expect(waits.length).toBe(1)
|
|
115
|
+
expect(waits[0]).toContain('4412')
|
|
116
|
+
expect(waits[0]).toContain('2026-08-07T02:58:03.000Z')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('force breaks a lock held by a live holder and runs the body', async () => {
|
|
120
|
+
await writeFile(
|
|
121
|
+
lockPath(root),
|
|
122
|
+
JSON.stringify({ pid: 4412, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
|
|
123
|
+
)
|
|
124
|
+
const result = await withStoreLock(root, async () => 'ran', {
|
|
125
|
+
cmd: 'import',
|
|
126
|
+
alive: () => true,
|
|
127
|
+
force: true,
|
|
128
|
+
})
|
|
129
|
+
expect(result).toBe('ran')
|
|
130
|
+
expect(existsSync(lockPath(root))).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('a lock file that never becomes readable is reported as stale, not waited out', async () => {
|
|
134
|
+
await writeFile(lockPath(root), 'not json at all')
|
|
135
|
+
const attempt = withStoreLock(root, async () => 'unreachable', {
|
|
136
|
+
cmd: 'import',
|
|
137
|
+
timeoutMs: 30_000,
|
|
138
|
+
})
|
|
139
|
+
await attempt.catch((e) => {
|
|
140
|
+
expect(e).toBeInstanceOf(LockError)
|
|
141
|
+
expect((e as LockError).kind).toBe('stale')
|
|
142
|
+
expect((e as LockError).message).toContain('unreadable')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// A lock file left empty forever (its creator crashed after the O_EXCL
|
|
147
|
+
// create but before it ever wrote a holder record) must not hang: an empty
|
|
148
|
+
// read is deliberately excluded from UNREADABLE_TOLERANCE (see readHolder),
|
|
149
|
+
// since counting it there is what caused real contention to misreport a
|
|
150
|
+
// live handoff as corruption, so the only remaining backstop is the overall
|
|
151
|
+
// deadline. This confirms that backstop actually fires instead of looping.
|
|
152
|
+
test('a lock file that stays empty forever times out rather than looping', async () => {
|
|
153
|
+
await writeFile(lockPath(root), '')
|
|
154
|
+
const attempt = withStoreLock(root, async () => 'unreachable', {
|
|
155
|
+
cmd: 'import',
|
|
156
|
+
timeoutMs: 120,
|
|
157
|
+
})
|
|
158
|
+
await attempt.catch((e) => {
|
|
159
|
+
expect(e).toBeInstanceOf(LockError)
|
|
160
|
+
expect((e as LockError).kind).toBe('timeout')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('two concurrent bodies in one process do not overlap', async () => {
|
|
165
|
+
const events: string[] = []
|
|
166
|
+
const body = (name: string) => async () => {
|
|
167
|
+
events.push(`${name}:enter`)
|
|
168
|
+
await Bun.sleep(20)
|
|
169
|
+
events.push(`${name}:exit`)
|
|
170
|
+
}
|
|
171
|
+
await Promise.all([
|
|
172
|
+
withStoreLock(root, body('a'), { cmd: 'import' }),
|
|
173
|
+
withStoreLock(root, body('b'), { cmd: 'import' }),
|
|
174
|
+
])
|
|
175
|
+
expect(events.length).toBe(4)
|
|
176
|
+
expect(events[1]).toBe(events[0]?.replace(':enter', ':exit'))
|
|
177
|
+
expect(events[3]).toBe(events[2]?.replace(':enter', ':exit'))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('processIsAlive is true for this process and false for an unused pid', () => {
|
|
181
|
+
expect(processIsAlive(process.pid)).toBe(true)
|
|
182
|
+
expect(processIsAlive(999999)).toBe(false)
|
|
183
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
|
|
3
|
+
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { assertNotUnderSource, findStoreRoot, storePaths } from '../paths.ts'
|
|
7
|
+
|
|
8
|
+
let root: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-paths-'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(root, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Platform support is detected once, synchronously, at module load, so
|
|
19
|
+
// test.skipIf's condition is known before any test runs rather than
|
|
20
|
+
// discovered mid-test.
|
|
21
|
+
|
|
22
|
+
function detectSymlinkSupport(): boolean {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), 'migrate-symlink-check-'))
|
|
24
|
+
try {
|
|
25
|
+
symlinkSync(join(dir, 'target'), join(dir, 'link'))
|
|
26
|
+
return true
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
} finally {
|
|
30
|
+
rmSync(dir, { recursive: true, force: true })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detectCaseInsensitiveFs(): boolean {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), 'migrate-case-check-'))
|
|
36
|
+
try {
|
|
37
|
+
mkdirSync(join(dir, 'src'))
|
|
38
|
+
return existsSync(join(dir, 'SRC'))
|
|
39
|
+
} finally {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SYMLINKS_SUPPORTED = detectSymlinkSupport()
|
|
45
|
+
const CASE_INSENSITIVE_FS = detectCaseInsensitiveFs()
|
|
46
|
+
|
|
47
|
+
test('findStoreRoot walks up to the directory holding .migrate', async () => {
|
|
48
|
+
await mkdir(join(root, '.migrate'), { recursive: true })
|
|
49
|
+
await mkdir(join(root, 'a', 'b'), { recursive: true })
|
|
50
|
+
expect(await findStoreRoot(join(root, 'a', 'b'))).toBe(root)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('findStoreRoot returns null when there is no store', async () => {
|
|
54
|
+
await mkdir(join(root, 'a'), { recursive: true })
|
|
55
|
+
expect(await findStoreRoot(join(root, 'a'))).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('storePaths puts every artifact under .migrate', () => {
|
|
59
|
+
const p = storePaths(root)
|
|
60
|
+
expect(p.config).toBe(join(root, '.migrate', 'config.toml'))
|
|
61
|
+
expect(p.elements).toBe(join(root, '.migrate', 'elements.jsonl'))
|
|
62
|
+
expect(p.queueDir).toBe(join(root, '.migrate', 'queue'))
|
|
63
|
+
expect(p.phases).toBe(join(root, '.migrate', 'phases.json'))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('assertNotUnderSource rejects a path inside the source tree', async () => {
|
|
67
|
+
const source = join(root, 'legacy')
|
|
68
|
+
await expect(assertNotUnderSource(join(source, 'x.md'), source)).rejects.toThrow(/source/)
|
|
69
|
+
await expect(assertNotUnderSource(join(source, '..', 'out.md'), source)).resolves.toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('assertNotUnderSource is not fooled by a sibling with a shared prefix', async () => {
|
|
73
|
+
const source = join(root, 'legacy')
|
|
74
|
+
await expect(
|
|
75
|
+
assertNotUnderSource(join(root, 'legacy-notes', 'x.md'), source),
|
|
76
|
+
).resolves.toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('assertNotUnderSource allows writes to the source parent directory', async () => {
|
|
80
|
+
const source = join(root, 'legacy')
|
|
81
|
+
await expect(assertNotUnderSource(root, source)).resolves.toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Important finding 1, bypass 1: a lexical-only comparison never resolves
|
|
85
|
+
// symlinks, so a target reached only through a symlinked directory can
|
|
86
|
+
// really land inside the source tree while comparing as "outside" on raw
|
|
87
|
+
// path text. Here `.migrate` itself -- the exact shape named in the report
|
|
88
|
+
// -- is a symlink into the source tree; storePaths' own path construction
|
|
89
|
+
// (`join(root, '.migrate', 'elements.jsonl')`) is used verbatim so this
|
|
90
|
+
// reproduces the real write path, not a synthetic one.
|
|
91
|
+
test.skipIf(!SYMLINKS_SUPPORTED)(
|
|
92
|
+
'assertNotUnderSource follows a .migrate directory symlinked into the source tree',
|
|
93
|
+
async () => {
|
|
94
|
+
const source = join(root, 'legacy')
|
|
95
|
+
const insideSource = join(source, 'landing-zone')
|
|
96
|
+
await mkdir(insideSource, { recursive: true })
|
|
97
|
+
await symlink(insideSource, join(root, '.migrate'))
|
|
98
|
+
const target = storePaths(root).elements
|
|
99
|
+
await expect(assertNotUnderSource(target, source)).rejects.toThrow(/source/)
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Important finding 1, bypass 2: on a case-insensitive volume, a config
|
|
104
|
+
// declaring the source at one case spelling and a store computed from a
|
|
105
|
+
// project root spelled with a different case name the same physical
|
|
106
|
+
// directory, but plain string comparison sees two unrelated paths. Skipped
|
|
107
|
+
// cleanly (not failed) on a case-sensitive filesystem, where this scenario
|
|
108
|
+
// cannot arise at all.
|
|
109
|
+
test.skipIf(!CASE_INSENSITIVE_FS)(
|
|
110
|
+
'assertNotUnderSource is not fooled by a case-variant spelling of the source root',
|
|
111
|
+
async () => {
|
|
112
|
+
const declaredSource = join(root, 'ci', 'src')
|
|
113
|
+
await mkdir(declaredSource, { recursive: true })
|
|
114
|
+
const target = join(root, 'ci', 'SRC', '.migrate', 'elements.jsonl')
|
|
115
|
+
await expect(assertNotUnderSource(target, declaredSource)).rejects.toThrow(/source/)
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// A write target need not exist yet -- that is the normal case for a store
|
|
120
|
+
// file about to be created for the first time -- so the fix must resolve
|
|
121
|
+
// containment from the nearest existing ancestor rather than requiring the
|
|
122
|
+
// target itself to exist.
|
|
123
|
+
test('assertNotUnderSource resolves containment even when the target does not exist yet', async () => {
|
|
124
|
+
const source = join(root, 'legacy')
|
|
125
|
+
await mkdir(source, { recursive: true })
|
|
126
|
+
const target = join(root, 'project', '.migrate', 'elements.jsonl')
|
|
127
|
+
await mkdir(join(root, 'project'), { recursive: true })
|
|
128
|
+
await expect(assertNotUnderSource(target, source)).resolves.toBeUndefined()
|
|
129
|
+
})
|