@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
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
|
+
});
|