@dalzoubi/dev-agents-sync 1.0.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/package.json +43 -0
- package/src/auth.mjs +95 -0
- package/src/cli.mjs +157 -0
- package/src/commands/check.mjs +77 -0
- package/src/commands/diff.mjs +68 -0
- package/src/commands/init.mjs +159 -0
- package/src/commands/status.mjs +71 -0
- package/src/commands/update.mjs +103 -0
- package/src/fetcher.mjs +356 -0
- package/src/index.mjs +14 -0
- package/src/lockfile.mjs +147 -0
- package/src/marker.mjs +61 -0
- package/src/range.mjs +50 -0
- package/src/writer.mjs +132 -0
- package/tests/auth.test.mjs +290 -0
- package/tests/fetcher.test.mjs +1247 -0
- package/tests/fixtures/release-v1.0.0/claude/agents/define.md +18 -0
- package/tests/fixtures/release-v1.0.0/claude/agents/test.md +17 -0
- package/tests/fixtures/release-v1.0.0/claude/commands/preflight.md +7 -0
- package/tests/fixtures/release-v1.0.0/cursor/rules/define.mdc +16 -0
- package/tests/fixtures/release-v1.0.0/cursor/rules/test.mdc +15 -0
- package/tests/init.test.mjs +514 -0
- package/tests/lockfile.test.mjs +202 -0
- package/tests/marker.test.mjs +190 -0
- package/tests/paths.test.mjs +212 -0
- package/tests/range.test.mjs +163 -0
- package/tests/status-check-diff.test.mjs +489 -0
- package/tests/update.test.mjs +322 -0
- package/tests/writer-normalize.test.mjs +99 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/lockfile.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for lockfile read/write/validation.
|
|
5
|
+
*
|
|
6
|
+
* The lockfile is `.dev-agents-sync.json` at the consumer repo root.
|
|
7
|
+
* Schema:
|
|
8
|
+
* {
|
|
9
|
+
* "$schema": "...",
|
|
10
|
+
* "source": "github:dalzoubi/dev-agents",
|
|
11
|
+
* "range": "^1",
|
|
12
|
+
* "resolvedVersion": "1.0.0",
|
|
13
|
+
* "targets": ["claude", "cursor"],
|
|
14
|
+
* "lastUpdated": "2026-04-24T00:00:00Z"
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Privacy invariant: the lockfile MUST NOT contain auth tokens.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, before, after } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
// The module under test — will not exist until implement runs.
|
|
27
|
+
// Tests must fail on missing module, not on setup errors.
|
|
28
|
+
import { readLockfile, writeLockfile, validateLockfile } from '../src/lockfile.mjs';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Helpers
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function makeTmpDir() {
|
|
35
|
+
return mkdtempSync(path.join(tmpdir(), 'das-lockfile-test-'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const VALID_LOCKFILE = {
|
|
39
|
+
$schema: 'https://raw.githubusercontent.com/dalzoubi/dev-agents/v1/schema/lockfile.schema.json',
|
|
40
|
+
source: 'github:dalzoubi/dev-agents',
|
|
41
|
+
range: '^1',
|
|
42
|
+
resolvedVersion: '1.0.0',
|
|
43
|
+
targets: ['claude', 'cursor'],
|
|
44
|
+
lastUpdated: '2026-04-24T00:00:00Z',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// validateLockfile — pure validation, no I/O
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('validateLockfile', () => {
|
|
52
|
+
it('accepts a fully valid lockfile object', () => {
|
|
53
|
+
assert.doesNotThrow(() => validateLockfile(VALID_LOCKFILE));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects when range is missing', () => {
|
|
57
|
+
const bad = { ...VALID_LOCKFILE };
|
|
58
|
+
delete bad.range;
|
|
59
|
+
assert.throws(() => validateLockfile(bad), /range/i);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects when range is not a string', () => {
|
|
63
|
+
assert.throws(() => validateLockfile({ ...VALID_LOCKFILE, range: 1 }), /range/i);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects when targets is not an array', () => {
|
|
67
|
+
assert.throws(() => validateLockfile({ ...VALID_LOCKFILE, targets: 'claude' }), /targets/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects when targets contains an unknown value', () => {
|
|
71
|
+
assert.throws(
|
|
72
|
+
() => validateLockfile({ ...VALID_LOCKFILE, targets: ['claude', 'vscode'] }),
|
|
73
|
+
/targets/i,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects when targets is an empty array', () => {
|
|
78
|
+
assert.throws(() => validateLockfile({ ...VALID_LOCKFILE, targets: [] }), /targets/i);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('accepts targets: ["claude"]', () => {
|
|
82
|
+
assert.doesNotThrow(() => validateLockfile({ ...VALID_LOCKFILE, targets: ['claude'] }));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('accepts targets: ["cursor"]', () => {
|
|
86
|
+
assert.doesNotThrow(() => validateLockfile({ ...VALID_LOCKFILE, targets: ['cursor'] }));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects when source is missing', () => {
|
|
90
|
+
const bad = { ...VALID_LOCKFILE };
|
|
91
|
+
delete bad.source;
|
|
92
|
+
assert.throws(() => validateLockfile(bad), /source/i);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects when resolvedVersion is missing', () => {
|
|
96
|
+
const bad = { ...VALID_LOCKFILE };
|
|
97
|
+
delete bad.resolvedVersion;
|
|
98
|
+
assert.throws(() => validateLockfile(bad), /resolvedVersion/i);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects a completely wrong shape (empty object)', () => {
|
|
102
|
+
assert.throws(() => validateLockfile({}), /range|source|targets/i);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects null', () => {
|
|
106
|
+
assert.throws(() => validateLockfile(null));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// writeLockfile / readLockfile — I/O round-trip
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe('writeLockfile / readLockfile round-trip', () => {
|
|
115
|
+
let tmpDir;
|
|
116
|
+
|
|
117
|
+
before(() => {
|
|
118
|
+
tmpDir = makeTmpDir();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
after(() => {
|
|
122
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('writes a valid lockfile and reads it back with identical shape', () => {
|
|
126
|
+
writeLockfile(tmpDir, VALID_LOCKFILE);
|
|
127
|
+
const lockfilePath = path.join(tmpDir, '.dev-agents-sync.json');
|
|
128
|
+
const raw = readFileSync(lockfilePath, 'utf8');
|
|
129
|
+
const parsed = JSON.parse(raw);
|
|
130
|
+
|
|
131
|
+
assert.equal(parsed.range, VALID_LOCKFILE.range);
|
|
132
|
+
assert.equal(parsed.resolvedVersion, VALID_LOCKFILE.resolvedVersion);
|
|
133
|
+
assert.deepEqual(parsed.targets, VALID_LOCKFILE.targets);
|
|
134
|
+
assert.equal(parsed.source, VALID_LOCKFILE.source);
|
|
135
|
+
assert.equal(parsed.lastUpdated, VALID_LOCKFILE.lastUpdated);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('readLockfile returns an object equal to what was written', () => {
|
|
139
|
+
writeLockfile(tmpDir, VALID_LOCKFILE);
|
|
140
|
+
const back = readLockfile(tmpDir);
|
|
141
|
+
assert.equal(back.range, VALID_LOCKFILE.range);
|
|
142
|
+
assert.equal(back.resolvedVersion, VALID_LOCKFILE.resolvedVersion);
|
|
143
|
+
assert.deepEqual(back.targets, VALID_LOCKFILE.targets);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('lockfile JSON is deterministic — same inputs produce byte-identical output', () => {
|
|
147
|
+
const dir1 = makeTmpDir();
|
|
148
|
+
const dir2 = makeTmpDir();
|
|
149
|
+
try {
|
|
150
|
+
writeLockfile(dir1, VALID_LOCKFILE);
|
|
151
|
+
writeLockfile(dir2, VALID_LOCKFILE);
|
|
152
|
+
const raw1 = readFileSync(path.join(dir1, '.dev-agents-sync.json'), 'utf8');
|
|
153
|
+
const raw2 = readFileSync(path.join(dir2, '.dev-agents-sync.json'), 'utf8');
|
|
154
|
+
assert.equal(raw1, raw2);
|
|
155
|
+
} finally {
|
|
156
|
+
rmSync(dir1, { recursive: true, force: true });
|
|
157
|
+
rmSync(dir2, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('lockfile does NOT contain the auth token', () => {
|
|
162
|
+
const secretToken = 'ghp_SUPERSECRETTOKEN12345';
|
|
163
|
+
// Even if a token was passed into context, writeLockfile should only persist the schema fields
|
|
164
|
+
writeLockfile(tmpDir, VALID_LOCKFILE, { token: secretToken });
|
|
165
|
+
const raw = readFileSync(path.join(tmpDir, '.dev-agents-sync.json'), 'utf8');
|
|
166
|
+
assert.ok(!raw.includes(secretToken), 'lockfile must not contain the auth token');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('readLockfile throws a structured error when the file is missing', () => {
|
|
170
|
+
const emptyDir = makeTmpDir();
|
|
171
|
+
try {
|
|
172
|
+
assert.throws(() => readLockfile(emptyDir), /no lockfile|not found|ENOENT/i);
|
|
173
|
+
} finally {
|
|
174
|
+
rmSync(emptyDir, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('readLockfile throws a structured error when the file is invalid JSON', () => {
|
|
179
|
+
const corruptDir = makeTmpDir();
|
|
180
|
+
try {
|
|
181
|
+
writeFileSync(path.join(corruptDir, '.dev-agents-sync.json'), '{not: json}', 'utf8');
|
|
182
|
+
assert.throws(() => readLockfile(corruptDir), /invalid|parse|JSON/i);
|
|
183
|
+
} finally {
|
|
184
|
+
rmSync(corruptDir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('readLockfile throws a structured validation error when schema is wrong', () => {
|
|
189
|
+
const badDir = makeTmpDir();
|
|
190
|
+
try {
|
|
191
|
+
writeFileSync(
|
|
192
|
+
path.join(badDir, '.dev-agents-sync.json'),
|
|
193
|
+
JSON.stringify({ source: 'github:dalzoubi/dev-agents' }),
|
|
194
|
+
'utf8',
|
|
195
|
+
);
|
|
196
|
+
// Missing required fields — should fail validation
|
|
197
|
+
assert.throws(() => readLockfile(badDir), /range|targets|invalid/i);
|
|
198
|
+
} finally {
|
|
199
|
+
rmSync(badDir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/marker.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for managed-file marker detection.
|
|
5
|
+
*
|
|
6
|
+
* Marker format (from spec):
|
|
7
|
+
* <!-- managed-by: dev-agents-sync vX.Y.Z -->
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - The marker is the first non-empty body line AFTER any YAML frontmatter.
|
|
11
|
+
* - Files with the marker are "managed" — safe to overwrite.
|
|
12
|
+
* - Files without the marker at the same path are "unmanaged" — refused without --force.
|
|
13
|
+
* - Detection works whether the file has frontmatter or not.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
|
|
19
|
+
import { hasMarker, buildMarker, extractMarkerVersion } from '../src/marker.mjs';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// buildMarker
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
describe('buildMarker', () => {
|
|
26
|
+
it('returns the correct marker string for a given version', () => {
|
|
27
|
+
assert.equal(buildMarker('1.0.0'), '<!-- managed-by: dev-agents-sync v1.0.0 -->');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('includes the v prefix', () => {
|
|
31
|
+
const marker = buildMarker('2.3.4');
|
|
32
|
+
assert.ok(marker.includes('v2.3.4'), 'marker must include version with v prefix');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('starts with <!-- and ends with -->', () => {
|
|
36
|
+
const marker = buildMarker('1.0.0');
|
|
37
|
+
assert.ok(marker.startsWith('<!--'));
|
|
38
|
+
assert.ok(marker.endsWith('-->'));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// hasMarker — file content with frontmatter
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe('hasMarker — files WITH YAML frontmatter', () => {
|
|
47
|
+
const version = '1.0.0';
|
|
48
|
+
|
|
49
|
+
it('detects marker as first body line after closing ---', () => {
|
|
50
|
+
const content = [
|
|
51
|
+
'---',
|
|
52
|
+
'name: "define"',
|
|
53
|
+
'description: "A test agent."',
|
|
54
|
+
'---',
|
|
55
|
+
'<!-- managed-by: dev-agents-sync v1.0.0 -->',
|
|
56
|
+
'Body text here.',
|
|
57
|
+
].join('\n');
|
|
58
|
+
assert.ok(hasMarker(content));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('detects marker after frontmatter with blank line between --- and marker', () => {
|
|
62
|
+
// The spec says "first non-empty body line" — blank lines between frontmatter and marker are OK
|
|
63
|
+
const content = [
|
|
64
|
+
'---',
|
|
65
|
+
'name: "define"',
|
|
66
|
+
'---',
|
|
67
|
+
'',
|
|
68
|
+
'<!-- managed-by: dev-agents-sync v1.0.0 -->',
|
|
69
|
+
'Body text here.',
|
|
70
|
+
].join('\n');
|
|
71
|
+
assert.ok(hasMarker(content));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns false when marker is absent (file has frontmatter but no marker)', () => {
|
|
75
|
+
const content = [
|
|
76
|
+
'---',
|
|
77
|
+
'name: "define"',
|
|
78
|
+
'description: "A test agent."',
|
|
79
|
+
'---',
|
|
80
|
+
'Body text without marker.',
|
|
81
|
+
].join('\n');
|
|
82
|
+
assert.ok(!hasMarker(content));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns false when a different comment appears first in the body', () => {
|
|
86
|
+
const content = [
|
|
87
|
+
'---',
|
|
88
|
+
'name: "define"',
|
|
89
|
+
'---',
|
|
90
|
+
'<!-- some other comment -->',
|
|
91
|
+
'Body text here.',
|
|
92
|
+
].join('\n');
|
|
93
|
+
assert.ok(!hasMarker(content));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns false when the marker appears AFTER other body content (not first body line)', () => {
|
|
97
|
+
const content = [
|
|
98
|
+
'---',
|
|
99
|
+
'name: "define"',
|
|
100
|
+
'---',
|
|
101
|
+
'Some text first.',
|
|
102
|
+
'<!-- managed-by: dev-agents-sync v1.0.0 -->',
|
|
103
|
+
].join('\n');
|
|
104
|
+
assert.ok(!hasMarker(content));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// hasMarker — file content WITHOUT frontmatter
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe('hasMarker — files WITHOUT YAML frontmatter', () => {
|
|
113
|
+
it('detects marker as first non-empty line when there is no frontmatter', () => {
|
|
114
|
+
const content = [
|
|
115
|
+
'<!-- managed-by: dev-agents-sync v1.0.0 -->',
|
|
116
|
+
'Body text here.',
|
|
117
|
+
].join('\n');
|
|
118
|
+
assert.ok(hasMarker(content));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns false when marker is absent and there is no frontmatter', () => {
|
|
122
|
+
const content = [
|
|
123
|
+
'# My Heading',
|
|
124
|
+
'Some body text.',
|
|
125
|
+
].join('\n');
|
|
126
|
+
assert.ok(!hasMarker(content));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('detects marker when there are leading blank lines (no frontmatter)', () => {
|
|
130
|
+
const content = [
|
|
131
|
+
'',
|
|
132
|
+
'<!-- managed-by: dev-agents-sync v1.0.0 -->',
|
|
133
|
+
'Body text here.',
|
|
134
|
+
].join('\n');
|
|
135
|
+
assert.ok(hasMarker(content));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns false for an empty file', () => {
|
|
139
|
+
assert.ok(!hasMarker(''));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// hasMarker — different marker versions
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe('hasMarker — version in marker is informational only', () => {
|
|
148
|
+
it('returns true for marker with v0.9.0 (older version is still a valid marker)', () => {
|
|
149
|
+
const content = [
|
|
150
|
+
'---',
|
|
151
|
+
'name: "define"',
|
|
152
|
+
'---',
|
|
153
|
+
'<!-- managed-by: dev-agents-sync v0.9.0 -->',
|
|
154
|
+
'Body.',
|
|
155
|
+
].join('\n');
|
|
156
|
+
assert.ok(hasMarker(content));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns true for marker with v9.99.0', () => {
|
|
160
|
+
const content = '<!-- managed-by: dev-agents-sync v9.99.0 -->\nBody.';
|
|
161
|
+
assert.ok(hasMarker(content));
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// extractMarkerVersion
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
describe('extractMarkerVersion', () => {
|
|
170
|
+
it('extracts version from a managed file with frontmatter', () => {
|
|
171
|
+
const content = [
|
|
172
|
+
'---',
|
|
173
|
+
'name: "define"',
|
|
174
|
+
'---',
|
|
175
|
+
'<!-- managed-by: dev-agents-sync v1.3.0 -->',
|
|
176
|
+
'Body.',
|
|
177
|
+
].join('\n');
|
|
178
|
+
assert.equal(extractMarkerVersion(content), '1.3.0');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('extracts version from a managed file without frontmatter', () => {
|
|
182
|
+
const content = '<!-- managed-by: dev-agents-sync v2.0.1 -->\nBody.';
|
|
183
|
+
assert.equal(extractMarkerVersion(content), '2.0.1');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('returns null when no marker is present', () => {
|
|
187
|
+
const content = '# No marker here\nBody.';
|
|
188
|
+
assert.equal(extractMarkerVersion(content), null);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/paths.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for cross-platform path handling.
|
|
5
|
+
*
|
|
6
|
+
* These tests ensure that on Windows (this machine) the CLI uses path.join /
|
|
7
|
+
* path-agnostic logic rather than hardcoded POSIX slashes when writing to the
|
|
8
|
+
* consumer repo filesystem.
|
|
9
|
+
*
|
|
10
|
+
* The FileMap keys from the content repo always use forward slashes (they come
|
|
11
|
+
* from the GitHub release's dist/ tree). The CLI must translate those into
|
|
12
|
+
* platform-appropriate paths before writing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import {
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
import { runInit } from '../src/commands/init.mjs';
|
|
30
|
+
import { hasMarker } from '../src/marker.mjs';
|
|
31
|
+
import { resolveConsumerPath } from '../src/writer.mjs';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Fixture helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
|
|
39
|
+
|
|
40
|
+
function buildFixtureFileMap() {
|
|
41
|
+
const map = {};
|
|
42
|
+
for (const target of ['claude', 'cursor']) {
|
|
43
|
+
const targetDir = path.join(FIXTURE_DIR, target);
|
|
44
|
+
if (!existsSync(targetDir)) continue;
|
|
45
|
+
collectFiles(targetDir, targetDir, map, target);
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
51
|
+
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const full = path.join(currentDir, entry.name);
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
collectFiles(baseDir, full, map, prefix);
|
|
56
|
+
} else {
|
|
57
|
+
const rel = path.relative(baseDir, full).replace(/\\/g, '/');
|
|
58
|
+
map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeFixtureFetcher() {
|
|
64
|
+
return async () => buildFixtureFileMap();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
function makeTmpDir() {
|
|
72
|
+
return mkdtempSync(path.join(tmpdir(), 'das-paths-test-'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// resolveConsumerPath — pure path translation unit
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe('resolveConsumerPath', () => {
|
|
80
|
+
it('translates "claude/agents/define.md" to .claude/agents/define.md', () => {
|
|
81
|
+
const consumerRoot = '/some/repo';
|
|
82
|
+
const result = resolveConsumerPath(consumerRoot, 'claude/agents/define.md');
|
|
83
|
+
// Must use path.join so it's platform-correct
|
|
84
|
+
const expected = path.join(consumerRoot, '.claude', 'agents', 'define.md');
|
|
85
|
+
assert.equal(result, expected);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('translates "claude/commands/preflight.md" to .claude/commands/preflight.md', () => {
|
|
89
|
+
const consumerRoot = '/some/repo';
|
|
90
|
+
const result = resolveConsumerPath(consumerRoot, 'claude/commands/preflight.md');
|
|
91
|
+
const expected = path.join(consumerRoot, '.claude', 'commands', 'preflight.md');
|
|
92
|
+
assert.equal(result, expected);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('translates "cursor/rules/define.mdc" to .cursor/rules/define.mdc', () => {
|
|
96
|
+
const consumerRoot = '/some/repo';
|
|
97
|
+
const result = resolveConsumerPath(consumerRoot, 'cursor/rules/define.mdc');
|
|
98
|
+
const expected = path.join(consumerRoot, '.cursor', 'rules', 'define.mdc');
|
|
99
|
+
assert.equal(result, expected);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('works with a Windows-style absolute consumer root path', () => {
|
|
103
|
+
// Simulate a Windows-style path
|
|
104
|
+
const consumerRoot = 'C:\\Users\\developer\\projects\\my-app';
|
|
105
|
+
const result = resolveConsumerPath(consumerRoot, 'claude/agents/define.md');
|
|
106
|
+
// Must not throw and must produce a valid path
|
|
107
|
+
assert.ok(typeof result === 'string', 'must return a string');
|
|
108
|
+
assert.ok(result.length > 0);
|
|
109
|
+
// Must end with the correct filename
|
|
110
|
+
assert.ok(result.endsWith('define.md'), `expected path ending with define.md, got: ${result}`);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not produce double-dot or traversal sequences', () => {
|
|
114
|
+
const consumerRoot = path.join(tmpdir(), 'consumer');
|
|
115
|
+
const result = resolveConsumerPath(consumerRoot, 'claude/agents/define.md');
|
|
116
|
+
assert.ok(!result.includes('..'), `path must not contain traversal, got: ${result}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not allow path traversal from a malicious FileMap key', () => {
|
|
120
|
+
const consumerRoot = path.join(tmpdir(), 'consumer');
|
|
121
|
+
// Attempt path traversal via the FileMap key
|
|
122
|
+
assert.throws(
|
|
123
|
+
() => resolveConsumerPath(consumerRoot, '../../../etc/passwd'),
|
|
124
|
+
/traversal|invalid|outside/i,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// init writes files with platform-correct paths
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
describe('init — platform-correct file paths', () => {
|
|
134
|
+
let consumerDir;
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
consumerDir = makeTmpDir();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('writes .claude/agents/define.md using the OS path separator', async () => {
|
|
145
|
+
await runInit(consumerDir, {
|
|
146
|
+
targets: ['claude'],
|
|
147
|
+
fetcher: makeFixtureFetcher(),
|
|
148
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// The file must exist using the OS path (path.join does the right thing)
|
|
152
|
+
const expectedPath = path.join(consumerDir, '.claude', 'agents', 'define.md');
|
|
153
|
+
assert.ok(
|
|
154
|
+
existsSync(expectedPath),
|
|
155
|
+
`file must exist at OS-correct path: ${expectedPath}`,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('writes .cursor/rules/define.mdc using the OS path separator', async () => {
|
|
160
|
+
await runInit(consumerDir, {
|
|
161
|
+
targets: ['cursor'],
|
|
162
|
+
fetcher: makeFixtureFetcher(),
|
|
163
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const expectedPath = path.join(consumerDir, '.cursor', 'rules', 'define.mdc');
|
|
167
|
+
assert.ok(
|
|
168
|
+
existsSync(expectedPath),
|
|
169
|
+
`file must exist at OS-correct path: ${expectedPath}`,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('written file content is readable via path.join on this platform', async () => {
|
|
174
|
+
await runInit(consumerDir, {
|
|
175
|
+
targets: ['claude'],
|
|
176
|
+
fetcher: makeFixtureFetcher(),
|
|
177
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const filePath = path.join(consumerDir, '.claude', 'agents', 'define.md');
|
|
181
|
+
let content;
|
|
182
|
+
assert.doesNotThrow(() => {
|
|
183
|
+
content = readFileSync(filePath, 'utf8');
|
|
184
|
+
});
|
|
185
|
+
assert.ok(hasMarker(content), 'written file must have the managed-by marker');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Lockfile path
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('lockfile path — platform correct', () => {
|
|
194
|
+
it('lockfile is written at <consumerRoot>/.dev-agents-sync.json using path.join', async () => {
|
|
195
|
+
const consumerDir = makeTmpDir();
|
|
196
|
+
try {
|
|
197
|
+
await runInit(consumerDir, {
|
|
198
|
+
targets: ['claude'],
|
|
199
|
+
fetcher: makeFixtureFetcher(),
|
|
200
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const expectedLockfilePath = path.join(consumerDir, '.dev-agents-sync.json');
|
|
204
|
+
assert.ok(
|
|
205
|
+
existsSync(expectedLockfilePath),
|
|
206
|
+
`lockfile must exist at OS-correct path: ${expectedLockfilePath}`,
|
|
207
|
+
);
|
|
208
|
+
} finally {
|
|
209
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|