@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/src/range.mjs ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Semver range resolution.
3
+ *
4
+ * resolveRange(availableTags, range) → highest matching version (no v prefix).
5
+ * Tags can be supplied with or without the leading `v`.
6
+ *
7
+ * Throws an actionable Error on no-match (so callers can map to exit code 1).
8
+ */
9
+
10
+ import semver from 'semver';
11
+
12
+ class RangeError extends Error {
13
+ constructor(message, { exitCode = 1 } = {}) {
14
+ super(message);
15
+ this.name = 'RangeError';
16
+ this.exitCode = exitCode;
17
+ }
18
+ }
19
+
20
+ function normalize(tag) {
21
+ if (typeof tag !== 'string') return null;
22
+ const stripped = tag.startsWith('v') ? tag.slice(1) : tag;
23
+ return semver.valid(stripped);
24
+ }
25
+
26
+ export function resolveRange(availableTags, range) {
27
+ if (!Array.isArray(availableTags) || availableTags.length === 0) {
28
+ throw new RangeError(
29
+ `no available tags to resolve range \`${range}\` against`,
30
+ );
31
+ }
32
+ const versions = availableTags
33
+ .map(normalize)
34
+ .filter((v) => v !== null);
35
+
36
+ if (versions.length === 0) {
37
+ throw new RangeError(
38
+ `no valid semver tags found to resolve range \`${range}\``,
39
+ );
40
+ }
41
+
42
+ const max = semver.maxSatisfying(versions, range);
43
+ if (!max) {
44
+ throw new RangeError(
45
+ `no version in available tags satisfies range \`${range}\` ` +
46
+ `(available: ${versions.join(', ')})`,
47
+ );
48
+ }
49
+ return max;
50
+ }
package/src/writer.mjs ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Filesystem writer + path translation.
3
+ *
4
+ * `resolveConsumerPath(consumerRoot, relPath)` maps a forward-slash key
5
+ * from a release FileMap (e.g. "claude/agents/define.md") to an OS-correct
6
+ * absolute path inside the consumer repo (e.g. "<root>/.claude/agents/define.md").
7
+ *
8
+ * Rejects path traversal (`..`).
9
+ */
10
+
11
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { hasMarker } from './marker.mjs';
14
+
15
+ const TARGET_PREFIX = {
16
+ claude: '.claude',
17
+ cursor: '.cursor',
18
+ };
19
+
20
+ // Subdirectories that uniquely identify a target. Used to normalize
21
+ // un-prefixed FileMap keys into target-prefixed form.
22
+ const SUBDIR_TO_TARGET = {
23
+ agents: 'claude',
24
+ commands: 'claude',
25
+ rules: 'cursor',
26
+ };
27
+
28
+ /**
29
+ * Normalizes a FileMap so every key starts with a target prefix
30
+ * ("claude/..." or "cursor/..."). Accepts both:
31
+ * - already-prefixed keys (left untouched)
32
+ * - un-prefixed keys whose first segment is one of the well-known
33
+ * target subdirs (agents, commands, rules)
34
+ *
35
+ * Unrecognized keys are dropped (with no error) to keep behavior conservative.
36
+ */
37
+ export function normalizeFileMap(fileMap) {
38
+ const out = {};
39
+ for (const [key, content] of Object.entries(fileMap)) {
40
+ const norm = key.replace(/\\/g, '/');
41
+ const first = norm.split('/')[0];
42
+ if (first === 'claude' || first === 'cursor') {
43
+ out[norm] = content;
44
+ continue;
45
+ }
46
+ const inferred = SUBDIR_TO_TARGET[first];
47
+ if (inferred) {
48
+ out[`${inferred}/${norm}`] = content;
49
+ continue;
50
+ }
51
+ // Unrecognized: dropping is preserved for backward compatibility,
52
+ // but emit a stderr warning so silent content-loss is visible.
53
+ // (Slice 4 will pin the fetcher to prefixed shape and remove tolerance.)
54
+ if (typeof process !== 'undefined' && process.stderr && typeof process.stderr.write === 'function') {
55
+ process.stderr.write(
56
+ `warn: dev-agents-sync: dropped unrecognized FileMap key '${key}' (no recognized prefix or subdir)\n`,
57
+ );
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ class PathError extends Error {
64
+ constructor(message, { exitCode = 1 } = {}) {
65
+ super(message);
66
+ this.name = 'PathError';
67
+ this.exitCode = exitCode;
68
+ }
69
+ }
70
+
71
+ export function resolveConsumerPath(consumerRoot, relPath) {
72
+ if (typeof relPath !== 'string' || relPath.length === 0) {
73
+ throw new PathError(`invalid path: ${JSON.stringify(relPath)}`);
74
+ }
75
+
76
+ // Normalize to forward slashes for inspection
77
+ const normalized = relPath.replace(/\\/g, '/');
78
+ const parts = normalized.split('/').filter((p) => p.length > 0);
79
+
80
+ if (parts.length === 0) {
81
+ throw new PathError(`invalid path (empty): ${JSON.stringify(relPath)}`);
82
+ }
83
+ for (const part of parts) {
84
+ if (part === '..' || part === '.') {
85
+ throw new PathError(
86
+ `invalid path traversal in ${JSON.stringify(relPath)} (refusing to write outside consumer root)`,
87
+ );
88
+ }
89
+ }
90
+
91
+ const [target, ...rest] = parts;
92
+ const prefix = TARGET_PREFIX[target];
93
+ if (!prefix) {
94
+ throw new PathError(
95
+ `invalid path: unknown target prefix in ${JSON.stringify(relPath)} ` +
96
+ `(allowed: ${Object.keys(TARGET_PREFIX).join(', ')})`,
97
+ );
98
+ }
99
+ if (rest.length === 0) {
100
+ throw new PathError(`invalid path (target only): ${JSON.stringify(relPath)}`);
101
+ }
102
+
103
+ return path.join(consumerRoot, prefix, ...rest);
104
+ }
105
+
106
+ /**
107
+ * Writes a single managed file to the consumer repo.
108
+ * Throws with exitCode 1 if the existing file is unmanaged and `force` is not set.
109
+ */
110
+ export function writeManagedFile({ absPath, relKey, content, force }) {
111
+ if (existsSync(absPath) && !force) {
112
+ const existing = readFileSync(absPath, 'utf8');
113
+ if (!hasMarker(existing)) {
114
+ const err = new Error(
115
+ `refusing to overwrite unmanaged file at ${relKey}: ` +
116
+ `${absPath} has no managed-by marker. ` +
117
+ `Delete or rename the file, or pass --force to overwrite.`,
118
+ );
119
+ err.exitCode = 1;
120
+ throw err;
121
+ }
122
+ }
123
+ mkdirSync(path.dirname(absPath), { recursive: true });
124
+ writeFileSync(absPath, content, 'utf8');
125
+ }
126
+
127
+ /** Returns true if the path under <root>/.claude or <root>/.cursor exists with content matching `expected`. */
128
+ export function fileMatchesExpected(absPath, expected) {
129
+ if (!existsSync(absPath)) return false;
130
+ const actual = readFileSync(absPath, 'utf8');
131
+ return actual === expected;
132
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * tests/auth.test.mjs
3
+ *
4
+ * Tests for the auth chain:
5
+ * 1. GITHUB_TOKEN env var → use it
6
+ * 2. GITHUB_TOKEN absent, `gh auth token` shellout succeeds → use that
7
+ * 3. Both fail → exit 2 with actionable message naming both fallbacks
8
+ *
9
+ * Privacy invariants (from spec):
10
+ * - Token is NEVER written to disk (lockfile after run must not contain the token).
11
+ * - Token is NEVER logged (stderr/stdout must not contain the token string).
12
+ *
13
+ * These tests drive the auth module in isolation so the full CLI is not needed.
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ import { resolveToken } from '../src/auth.mjs';
23
+ import { writeLockfile } from '../src/lockfile.mjs';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function makeTmpDir() {
30
+ return mkdtempSync(path.join(tmpdir(), 'das-auth-test-'));
31
+ }
32
+
33
+ /**
34
+ * Runs resolveToken with specific env and gh-shellout overrides.
35
+ * The production module accepts an injectable `ghAuthToken` function so tests
36
+ * can avoid real `gh` calls.
37
+ */
38
+ async function resolveTokenWith({ envToken, ghTokenResult, ghTokenError } = {}) {
39
+ const env = envToken ? { GITHUB_TOKEN: envToken } : {};
40
+
41
+ const ghAuthToken = ghTokenError
42
+ ? async () => { throw ghTokenError; }
43
+ : async () => ghTokenResult ?? null;
44
+
45
+ return resolveToken({ env, ghAuthToken });
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Token resolution — GITHUB_TOKEN env var
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe('resolveToken — GITHUB_TOKEN env var', () => {
53
+ it('returns the token from GITHUB_TOKEN when set', async () => {
54
+ const token = 'ghp_ENVTOKEN123456';
55
+ const result = await resolveTokenWith({ envToken: token });
56
+ assert.equal(result, token);
57
+ });
58
+
59
+ it('does not call ghAuthToken shellout when GITHUB_TOKEN is present', async () => {
60
+ let ghCalled = false;
61
+ const env = { GITHUB_TOKEN: 'ghp_TEST' };
62
+
63
+ await resolveToken({
64
+ env,
65
+ ghAuthToken: async () => {
66
+ ghCalled = true;
67
+ return 'should-not-be-returned';
68
+ },
69
+ });
70
+
71
+ assert.ok(!ghCalled, 'ghAuthToken must not be called when GITHUB_TOKEN is set');
72
+ });
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Token resolution — gh auth token fallback
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe('resolveToken — gh auth token fallback', () => {
80
+ it('returns token from gh shellout when GITHUB_TOKEN is absent', async () => {
81
+ const ghToken = 'ghp_GHTOKEN789012';
82
+ const result = await resolveTokenWith({ ghTokenResult: ghToken });
83
+ assert.equal(result, ghToken);
84
+ });
85
+
86
+ it('returns null-or-throws when GITHUB_TOKEN is absent and gh returns empty', async () => {
87
+ let threw = false;
88
+ let result = null;
89
+
90
+ try {
91
+ result = await resolveTokenWith({ ghTokenResult: '' });
92
+ } catch {
93
+ threw = true;
94
+ }
95
+
96
+ // Both behaviors are acceptable: throw or return null/undefined
97
+ // What is NOT acceptable is returning a truthy empty string
98
+ if (!threw) {
99
+ assert.ok(
100
+ result === null || result === undefined,
101
+ `expected null/undefined when gh returns empty, got: ${JSON.stringify(result)}`,
102
+ );
103
+ }
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Token resolution — both fail → actionable error
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe('resolveToken — both fail', () => {
112
+ it('throws an error when both GITHUB_TOKEN and gh auth token are unavailable', async () => {
113
+ await assert.rejects(
114
+ resolveTokenWith({
115
+ ghTokenError: new Error('gh: not logged in'),
116
+ }),
117
+ );
118
+ });
119
+
120
+ it('error message mentions GITHUB_TOKEN', async () => {
121
+ let message = '';
122
+ try {
123
+ await resolveTokenWith({ ghTokenError: new Error('not logged in') });
124
+ } catch (err) {
125
+ message = err.message;
126
+ }
127
+ assert.ok(
128
+ message.includes('GITHUB_TOKEN'),
129
+ `error must mention GITHUB_TOKEN, got: ${message}`,
130
+ );
131
+ });
132
+
133
+ it('error message mentions `gh auth login` as a fallback', async () => {
134
+ let message = '';
135
+ try {
136
+ await resolveTokenWith({ ghTokenError: new Error('not logged in') });
137
+ } catch (err) {
138
+ message = err.message;
139
+ }
140
+ assert.ok(
141
+ message.includes('gh auth login'),
142
+ `error must mention 'gh auth login', got: ${message}`,
143
+ );
144
+ });
145
+
146
+ it('error produces exit code 2', async () => {
147
+ let exitCode = null;
148
+ try {
149
+ await resolveTokenWith({ ghTokenError: new Error('not logged in') });
150
+ } catch (err) {
151
+ exitCode = err.exitCode;
152
+ }
153
+ assert.equal(exitCode, 2, 'auth failure must produce exit code 2');
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Privacy: token never written to disk
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe('auth — token never written to disk', () => {
162
+ let consumerDir;
163
+
164
+ beforeEach(() => {
165
+ consumerDir = makeTmpDir();
166
+ });
167
+
168
+ afterEach(() => {
169
+ rmSync(consumerDir, { recursive: true, force: true });
170
+ });
171
+
172
+ it('lockfile does not contain the token after writeLockfile', () => {
173
+ const SECRET_TOKEN = 'ghp_SUPERSECRET_SHOULD_NEVER_APPEAR';
174
+
175
+ writeLockfile(consumerDir, {
176
+ source: 'github:dalzoubi/dev-agents',
177
+ range: '^1',
178
+ resolvedVersion: '1.0.0',
179
+ targets: ['claude'],
180
+ lastUpdated: '2026-04-25T00:00:00Z',
181
+ });
182
+
183
+ const lockfileContent = readFileSync(
184
+ path.join(consumerDir, '.dev-agents-sync.json'),
185
+ 'utf8',
186
+ );
187
+
188
+ assert.ok(
189
+ !lockfileContent.includes(SECRET_TOKEN),
190
+ 'lockfile must never contain the auth token',
191
+ );
192
+ });
193
+
194
+ it('lockfile does not contain any string that looks like a GitHub token', () => {
195
+ writeLockfile(consumerDir, {
196
+ source: 'github:dalzoubi/dev-agents',
197
+ range: '^1',
198
+ resolvedVersion: '1.0.0',
199
+ targets: ['claude'],
200
+ lastUpdated: '2026-04-25T00:00:00Z',
201
+ });
202
+
203
+ const lockfileContent = readFileSync(
204
+ path.join(consumerDir, '.dev-agents-sync.json'),
205
+ 'utf8',
206
+ );
207
+
208
+ // GitHub token patterns: ghp_, gho_, github_pat_, etc.
209
+ const ghTokenPattern = /gh[pos]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+/;
210
+ assert.ok(
211
+ !ghTokenPattern.test(lockfileContent),
212
+ 'lockfile must not contain any GitHub token pattern',
213
+ );
214
+ });
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Privacy: token never logged
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe('auth — token never in logs', () => {
222
+ it('resolveToken does not emit the token to stdout', async () => {
223
+ const token = 'ghp_NEVERLOG987654';
224
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
225
+ let stdoutCapture = '';
226
+
227
+ process.stdout.write = (chunk, ...args) => {
228
+ stdoutCapture += chunk.toString();
229
+ return originalStdoutWrite(chunk, ...args);
230
+ };
231
+
232
+ try {
233
+ await resolveTokenWith({ envToken: token });
234
+ } finally {
235
+ process.stdout.write = originalStdoutWrite;
236
+ }
237
+
238
+ assert.ok(
239
+ !stdoutCapture.includes(token),
240
+ `token must not appear in stdout, captured: ${stdoutCapture.substring(0, 200)}`,
241
+ );
242
+ });
243
+
244
+ it('resolveToken does not emit the token to stderr', async () => {
245
+ const token = 'ghp_NEVERLOGERR987654';
246
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
247
+ let stderrCapture = '';
248
+
249
+ process.stderr.write = (chunk, ...args) => {
250
+ stderrCapture += chunk.toString();
251
+ return originalStderrWrite(chunk, ...args);
252
+ };
253
+
254
+ try {
255
+ await resolveTokenWith({ envToken: token });
256
+ } finally {
257
+ process.stderr.write = originalStderrWrite;
258
+ }
259
+
260
+ assert.ok(
261
+ !stderrCapture.includes(token),
262
+ `token must not appear in stderr, captured: ${stderrCapture.substring(0, 200)}`,
263
+ );
264
+ });
265
+
266
+ it('error message from auth failure does not contain any token value', async () => {
267
+ // Even a partial token must not appear in the error message
268
+ let errorMessage = '';
269
+ const fakeToken = 'ghp_LEAKED_TOKEN';
270
+
271
+ try {
272
+ // Simulate gh returning a token but then failing in a way that might leak it
273
+ await resolveToken({
274
+ env: {},
275
+ ghAuthToken: async () => {
276
+ const err = new Error(`Error: token ${fakeToken} is invalid`);
277
+ err.exitCode = 2;
278
+ throw err;
279
+ },
280
+ });
281
+ } catch (err) {
282
+ errorMessage = err.message;
283
+ }
284
+
285
+ assert.ok(
286
+ !errorMessage.includes(fakeToken),
287
+ `error message must not contain leaked token, got: ${errorMessage}`,
288
+ );
289
+ });
290
+ });