@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/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dalzoubi/dev-agents-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dev-agents-sync": "./src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.mjs",
|
|
11
|
+
"./lockfile": "./src/lockfile.mjs",
|
|
12
|
+
"./marker": "./src/marker.mjs",
|
|
13
|
+
"./range": "./src/range.mjs",
|
|
14
|
+
"./fetcher": "./src/fetcher.mjs",
|
|
15
|
+
"./writer": "./src/writer.mjs",
|
|
16
|
+
"./auth": "./src/auth.mjs",
|
|
17
|
+
"./commands/init": "./src/commands/init.mjs",
|
|
18
|
+
"./commands/update": "./src/commands/update.mjs",
|
|
19
|
+
"./commands/status": "./src/commands/status.mjs",
|
|
20
|
+
"./commands/check": "./src/commands/check.mjs",
|
|
21
|
+
"./commands/diff": "./src/commands/diff.mjs"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "node --watch src/cli.mjs",
|
|
25
|
+
"build": "echo 'No build step for v1 (pure ESM)'",
|
|
26
|
+
"test": "node --test tests/*.test.mjs",
|
|
27
|
+
"test:e2e": "node --test tests/e2e/*.test.mjs",
|
|
28
|
+
"lint": "npx eslint ."
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"semver": "^7.6.0",
|
|
32
|
+
"tar": "^7.4.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"node": ">=20.0.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20.0.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth chain.
|
|
3
|
+
*
|
|
4
|
+
* 1. explicit `--token` flag (passed via { tokenFlag })
|
|
5
|
+
* 2. GITHUB_TOKEN env var
|
|
6
|
+
* 3. `gh auth token` shellout (injectable for tests)
|
|
7
|
+
*
|
|
8
|
+
* On failure, throw an actionable error with exitCode = 2 that names BOTH
|
|
9
|
+
* fallbacks (`GITHUB_TOKEN` and `gh auth login`). The token value is never
|
|
10
|
+
* embedded in error messages or logged.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
class AuthError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'AuthError';
|
|
19
|
+
this.exitCode = 2;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Default `gh auth token` shellout. Returns the trimmed token or null. */
|
|
24
|
+
async function defaultGhAuthToken() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
let child;
|
|
27
|
+
try {
|
|
28
|
+
child = spawn('gh', ['auth', 'token'], { shell: false });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
reject(err);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let stdout = '';
|
|
35
|
+
let stderr = '';
|
|
36
|
+
child.stdout.on('data', (chunk) => {
|
|
37
|
+
stdout += chunk.toString();
|
|
38
|
+
});
|
|
39
|
+
child.stderr.on('data', (chunk) => {
|
|
40
|
+
stderr += chunk.toString();
|
|
41
|
+
});
|
|
42
|
+
child.on('error', (err) => reject(err));
|
|
43
|
+
child.on('close', (code) => {
|
|
44
|
+
if (code === 0) {
|
|
45
|
+
const token = stdout.trim();
|
|
46
|
+
resolve(token.length > 0 ? token : null);
|
|
47
|
+
} else {
|
|
48
|
+
// Do NOT include stderr verbatim — it's unlikely to leak a token,
|
|
49
|
+
// but be conservative.
|
|
50
|
+
reject(new Error(`gh auth token exited with code ${code}`));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolves an auth token following the documented chain.
|
|
58
|
+
*
|
|
59
|
+
* Options:
|
|
60
|
+
* tokenFlag — explicit token from --token flag (highest priority)
|
|
61
|
+
* env — process.env-like object (default: process.env)
|
|
62
|
+
* ghAuthToken — async function returning a token or null/empty (injectable)
|
|
63
|
+
*/
|
|
64
|
+
export async function resolveToken({ tokenFlag, env, ghAuthToken } = {}) {
|
|
65
|
+
if (tokenFlag && typeof tokenFlag === 'string' && tokenFlag.length > 0) {
|
|
66
|
+
return tokenFlag;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const envSource = env ?? process.env;
|
|
70
|
+
const envToken = envSource && envSource.GITHUB_TOKEN;
|
|
71
|
+
if (typeof envToken === 'string' && envToken.length > 0) {
|
|
72
|
+
return envToken;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const shellout = ghAuthToken ?? defaultGhAuthToken;
|
|
76
|
+
let ghToken = null;
|
|
77
|
+
let ghErr = null;
|
|
78
|
+
try {
|
|
79
|
+
ghToken = await shellout();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
ghErr = err;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof ghToken === 'string' && ghToken.length > 0) {
|
|
85
|
+
return ghToken;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Both fallbacks failed. Throw an actionable error. Do not echo any
|
|
89
|
+
// upstream value (which might have been a token-shaped string).
|
|
90
|
+
throw new AuthError(
|
|
91
|
+
'authentication required: no token found.\n' +
|
|
92
|
+
' Set the GITHUB_TOKEN environment variable, or run `gh auth login` ' +
|
|
93
|
+
'to authenticate the GitHub CLI.',
|
|
94
|
+
);
|
|
95
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dev-agents-sync — CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* This is intentionally minimal in v1: tests drive the runX() functions
|
|
6
|
+
* directly. The CLI binary parses flags and dispatches.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
|
|
11
|
+
import { runInit } from './commands/init.mjs';
|
|
12
|
+
import { runUpdate } from './commands/update.mjs';
|
|
13
|
+
import { runStatus } from './commands/status.mjs';
|
|
14
|
+
import { runCheck } from './commands/check.mjs';
|
|
15
|
+
import { runDiff } from './commands/diff.mjs';
|
|
16
|
+
import {
|
|
17
|
+
defaultGithubFetcher,
|
|
18
|
+
defaultGithubTagLister,
|
|
19
|
+
STUB_NOT_IMPLEMENTED,
|
|
20
|
+
} from './fetcher.mjs';
|
|
21
|
+
import { resolveToken } from './auth.mjs';
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const args = { _: [], flags: {} };
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const a = argv[i];
|
|
27
|
+
if (a.startsWith('--')) {
|
|
28
|
+
const eq = a.indexOf('=');
|
|
29
|
+
let key, value;
|
|
30
|
+
if (eq >= 0) {
|
|
31
|
+
key = a.slice(2, eq);
|
|
32
|
+
value = a.slice(eq + 1);
|
|
33
|
+
} else {
|
|
34
|
+
key = a.slice(2);
|
|
35
|
+
const next = argv[i + 1];
|
|
36
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
37
|
+
value = next;
|
|
38
|
+
i += 1;
|
|
39
|
+
} else {
|
|
40
|
+
value = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
args.flags[key] = value;
|
|
44
|
+
} else {
|
|
45
|
+
args._.push(a);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return args;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fail(message, code = 2) {
|
|
52
|
+
process.stderr.write(`error: ${message}\n`);
|
|
53
|
+
process.exit(code);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
const { _: positional, flags } = parseArgs(argv);
|
|
59
|
+
const sub = positional[0];
|
|
60
|
+
|
|
61
|
+
if (!sub || sub === 'help' || flags.help) {
|
|
62
|
+
process.stdout.write(
|
|
63
|
+
'Usage: dev-agents-sync <init|update|status|check|diff> [flags]\n',
|
|
64
|
+
);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const cwd = process.cwd();
|
|
69
|
+
|
|
70
|
+
let token;
|
|
71
|
+
try {
|
|
72
|
+
token = await resolveToken({
|
|
73
|
+
tokenFlag: typeof flags.token === 'string' ? flags.token : undefined,
|
|
74
|
+
env: process.env,
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// For status/check/diff/init/update we need a token — bail early.
|
|
78
|
+
fail(err.message, err.exitCode ?? 2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fetcher = async (repo, tag) => defaultGithubFetcher(repo, tag, token);
|
|
82
|
+
let availableTags;
|
|
83
|
+
try {
|
|
84
|
+
availableTags = await defaultGithubTagLister('dalzoubi/dev-agents', token);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Only swallow the stub's specific "not implemented" sentinel. Real
|
|
87
|
+
// network/auth/parse failures must surface to the user with exit 2.
|
|
88
|
+
if (err && err.code === STUB_NOT_IMPLEMENTED) {
|
|
89
|
+
availableTags = [];
|
|
90
|
+
} else {
|
|
91
|
+
fail(err.message ?? String(err), err?.exitCode ?? 2);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const targetsFlag = (() => {
|
|
96
|
+
if (flags.targets === undefined) return undefined;
|
|
97
|
+
if (flags.targets === 'auto') return 'auto';
|
|
98
|
+
if (typeof flags.targets === 'string') {
|
|
99
|
+
return flags.targets.split(',').map((s) => s.trim()).filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
})();
|
|
103
|
+
|
|
104
|
+
const commonOpts = {
|
|
105
|
+
fetcher,
|
|
106
|
+
availableTags,
|
|
107
|
+
token,
|
|
108
|
+
targets: targetsFlag,
|
|
109
|
+
range: typeof flags.range === 'string' ? flags.range : undefined,
|
|
110
|
+
force: Boolean(flags.force),
|
|
111
|
+
dryRun: Boolean(flags['dry-run']),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
switch (sub) {
|
|
116
|
+
case 'init': {
|
|
117
|
+
const r = await runInit(cwd, commonOpts);
|
|
118
|
+
process.stdout.write(`init complete: v${r.resolvedVersion}\n`);
|
|
119
|
+
process.exit(0);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'update': {
|
|
123
|
+
const r = await runUpdate(cwd, commonOpts);
|
|
124
|
+
if (r.upToDate) process.stdout.write(`already up to date at v${r.resolvedVersion}\n`);
|
|
125
|
+
else process.stdout.write(`updated to v${r.resolvedVersion}\n`);
|
|
126
|
+
process.exit(0);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'status': {
|
|
130
|
+
const r = await runStatus(cwd, commonOpts);
|
|
131
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
132
|
+
process.exit(0);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'check': {
|
|
136
|
+
await runCheck(cwd, commonOpts);
|
|
137
|
+
process.stdout.write('in sync\n');
|
|
138
|
+
process.exit(0);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'diff': {
|
|
142
|
+
const r = await runDiff(cwd, commonOpts);
|
|
143
|
+
if (r && r.diff) process.stdout.write(r.diff);
|
|
144
|
+
process.exit(0);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
default:
|
|
148
|
+
fail(`unknown subcommand: ${sub}`, 2);
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
fail(err.message, err.exitCode ?? 2);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main().catch((err) => {
|
|
156
|
+
fail(err.message ?? String(err), 2);
|
|
157
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `check` subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Exit 0 — local managed files match expected emission for the resolved version.
|
|
5
|
+
* Exit 1 — drift detected.
|
|
6
|
+
* Exit 2 — tooling error (auth, network, etc.).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
import { readLockfile } from '../lockfile.mjs';
|
|
12
|
+
import { resolveConsumerPath, normalizeFileMap } from '../writer.mjs';
|
|
13
|
+
|
|
14
|
+
function filterFileMapByTargets(fileMap, targets) {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [key, content] of Object.entries(fileMap)) {
|
|
17
|
+
const prefix = key.split('/')[0];
|
|
18
|
+
if (targets.includes(prefix)) {
|
|
19
|
+
out[key] = content;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runCheck(consumerCwd, opts = {}) {
|
|
26
|
+
const { fetcher, token, repo = 'dalzoubi/dev-agents' } = opts;
|
|
27
|
+
|
|
28
|
+
if (typeof fetcher !== 'function') {
|
|
29
|
+
const err = new Error('runCheck requires an injected fetcher');
|
|
30
|
+
err.exitCode = 2;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lock = readLockfile(consumerCwd);
|
|
35
|
+
|
|
36
|
+
let fileMap;
|
|
37
|
+
try {
|
|
38
|
+
fileMap = await fetcher(repo, `v${lock.resolvedVersion}`, token);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const wrapped = new Error(
|
|
41
|
+
`tooling error while fetching v${lock.resolvedVersion}: ${err.message}`,
|
|
42
|
+
);
|
|
43
|
+
wrapped.exitCode = 2;
|
|
44
|
+
wrapped.cause = err;
|
|
45
|
+
throw wrapped;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const scoped = filterFileMapByTargets(normalizeFileMap(fileMap), lock.targets);
|
|
49
|
+
|
|
50
|
+
const drifted = [];
|
|
51
|
+
|
|
52
|
+
for (const [relKey, expected] of Object.entries(scoped)) {
|
|
53
|
+
const absPath = resolveConsumerPath(consumerCwd, relKey);
|
|
54
|
+
if (!existsSync(absPath)) {
|
|
55
|
+
// Missing-locally is the canonical CI drift case: an expected managed
|
|
56
|
+
// file was deleted (or never written). Mark it explicitly so consumers
|
|
57
|
+
// can tell deletion drift from content drift in the message.
|
|
58
|
+
drifted.push(`<missing> ${relKey}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const actual = readFileSync(absPath, 'utf8');
|
|
62
|
+
if (actual !== expected) {
|
|
63
|
+
drifted.push(relKey);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (drifted.length === 0) {
|
|
68
|
+
return { inSync: true, resolvedVersion: lock.resolvedVersion };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const err = new Error(
|
|
72
|
+
`drift detected for v${lock.resolvedVersion} — drifted: ${drifted.join(', ')}`,
|
|
73
|
+
);
|
|
74
|
+
err.exitCode = 1;
|
|
75
|
+
err.drifted = drifted;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `diff` subcommand. Prints unified diff between local managed files and
|
|
3
|
+
* what the resolved version would emit. Always exits 0 (informational).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
import { readLockfile } from '../lockfile.mjs';
|
|
9
|
+
import { resolveConsumerPath, normalizeFileMap } from '../writer.mjs';
|
|
10
|
+
|
|
11
|
+
function filterFileMapByTargets(fileMap, targets) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const [key, content] of Object.entries(fileMap)) {
|
|
14
|
+
const prefix = key.split('/')[0];
|
|
15
|
+
if (targets.includes(prefix)) {
|
|
16
|
+
out[key] = content;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Naive line-based unified diff. Sufficient for this tool's purposes. */
|
|
23
|
+
function unifiedDiff(relKey, actual, expected) {
|
|
24
|
+
if (actual === expected) return '';
|
|
25
|
+
const aLines = actual.split('\n');
|
|
26
|
+
const bLines = expected.split('\n');
|
|
27
|
+
const out = [
|
|
28
|
+
`--- a/${relKey}`,
|
|
29
|
+
`+++ b/${relKey}`,
|
|
30
|
+
];
|
|
31
|
+
const max = Math.max(aLines.length, bLines.length);
|
|
32
|
+
for (let i = 0; i < max; i++) {
|
|
33
|
+
const a = aLines[i];
|
|
34
|
+
const b = bLines[i];
|
|
35
|
+
if (a === b) continue;
|
|
36
|
+
if (a !== undefined) out.push(`-${a}`);
|
|
37
|
+
if (b !== undefined) out.push(`+${b}`);
|
|
38
|
+
}
|
|
39
|
+
return out.join('\n') + '\n';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runDiff(consumerCwd, opts = {}) {
|
|
43
|
+
const { fetcher, token, repo = 'dalzoubi/dev-agents' } = opts;
|
|
44
|
+
|
|
45
|
+
if (typeof fetcher !== 'function') {
|
|
46
|
+
const err = new Error('runDiff requires an injected fetcher');
|
|
47
|
+
err.exitCode = 2;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lock = readLockfile(consumerCwd);
|
|
52
|
+
|
|
53
|
+
const fileMap = await fetcher(repo, `v${lock.resolvedVersion}`, token);
|
|
54
|
+
const scoped = filterFileMapByTargets(normalizeFileMap(fileMap), lock.targets);
|
|
55
|
+
|
|
56
|
+
let combined = '';
|
|
57
|
+
const perFile = {};
|
|
58
|
+
|
|
59
|
+
for (const [relKey, expected] of Object.entries(scoped)) {
|
|
60
|
+
const absPath = resolveConsumerPath(consumerCwd, relKey);
|
|
61
|
+
const actual = existsSync(absPath) ? readFileSync(absPath, 'utf8') : '';
|
|
62
|
+
const d = unifiedDiff(relKey, actual, expected);
|
|
63
|
+
perFile[relKey] = d;
|
|
64
|
+
combined += d;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { diff: combined, perFile };
|
|
68
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `init` subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Creates the lockfile and writes managed files for the chosen targets.
|
|
5
|
+
* Refuses to overwrite unmanaged collisions without --force.
|
|
6
|
+
* Supports --dry-run (no writes).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { writeLockfile, DEFAULT_SOURCE } from '../lockfile.mjs';
|
|
13
|
+
import { resolveRange } from '../range.mjs';
|
|
14
|
+
import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writer.mjs';
|
|
15
|
+
import { hasMarker } from '../marker.mjs';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_RANGE = '^1';
|
|
18
|
+
const ALL_TARGETS = ['claude', 'cursor'];
|
|
19
|
+
|
|
20
|
+
function autoDetectTargets(consumerCwd) {
|
|
21
|
+
const claudeExists = existsSync(path.join(consumerCwd, '.claude'));
|
|
22
|
+
const cursorExists = existsSync(path.join(consumerCwd, '.cursor'));
|
|
23
|
+
if (!claudeExists && !cursorExists) return [...ALL_TARGETS];
|
|
24
|
+
const out = [];
|
|
25
|
+
if (claudeExists) out.push('claude');
|
|
26
|
+
if (cursorExists) out.push('cursor');
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeTargets(targetsOpt, consumerCwd) {
|
|
31
|
+
if (targetsOpt === undefined || targetsOpt === null || targetsOpt === 'auto') {
|
|
32
|
+
return autoDetectTargets(consumerCwd);
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(targetsOpt)) {
|
|
35
|
+
if (targetsOpt.length === 0) return autoDetectTargets(consumerCwd);
|
|
36
|
+
return [...targetsOpt];
|
|
37
|
+
}
|
|
38
|
+
if (typeof targetsOpt === 'string') {
|
|
39
|
+
return targetsOpt.split(',').map((s) => s.trim()).filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`invalid --targets value: ${JSON.stringify(targetsOpt)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function filterFileMapByTargets(fileMap, targets) {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const [key, content] of Object.entries(fileMap)) {
|
|
47
|
+
const prefix = key.split('/')[0];
|
|
48
|
+
if (targets.includes(prefix)) {
|
|
49
|
+
out[key] = content;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* runInit — programmatic entry point for the init subcommand.
|
|
57
|
+
*
|
|
58
|
+
* Options:
|
|
59
|
+
* targets — array | "auto" | undefined (default: auto-detect)
|
|
60
|
+
* range — semver range (default: "^1")
|
|
61
|
+
* force — overwrite unmanaged collisions
|
|
62
|
+
* dryRun — print plan, no writes
|
|
63
|
+
* token — auth token (passed to fetcher; never persisted)
|
|
64
|
+
* fetcher — async (repo, tag, token) => FileMap (injectable)
|
|
65
|
+
* availableTags — array of tag strings (injectable; in production this
|
|
66
|
+
* will be supplied by a tag lister)
|
|
67
|
+
* source — defaults to "github:dalzoubi/dev-agents"
|
|
68
|
+
* repo — defaults to "dalzoubi/dev-agents"
|
|
69
|
+
* log — { info, warn, error } sink (default: stderr)
|
|
70
|
+
*/
|
|
71
|
+
export async function runInit(consumerCwd, opts = {}) {
|
|
72
|
+
const {
|
|
73
|
+
targets,
|
|
74
|
+
range = DEFAULT_RANGE,
|
|
75
|
+
force = false,
|
|
76
|
+
dryRun = false,
|
|
77
|
+
token,
|
|
78
|
+
fetcher,
|
|
79
|
+
availableTags,
|
|
80
|
+
source = DEFAULT_SOURCE,
|
|
81
|
+
repo = 'dalzoubi/dev-agents',
|
|
82
|
+
} = opts;
|
|
83
|
+
|
|
84
|
+
if (typeof fetcher !== 'function') {
|
|
85
|
+
const err = new Error('runInit requires an injected fetcher');
|
|
86
|
+
err.exitCode = 2;
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(availableTags)) {
|
|
90
|
+
const err = new Error('runInit requires availableTags');
|
|
91
|
+
err.exitCode = 2;
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const resolvedTargets = normalizeTargets(targets, consumerCwd);
|
|
96
|
+
if (resolvedTargets.length === 0) {
|
|
97
|
+
const err = new Error('no targets selected; specify --targets or run in a repo with .claude/ or .cursor/');
|
|
98
|
+
err.exitCode = 1;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const resolvedVersion = resolveRange(availableTags, range);
|
|
103
|
+
|
|
104
|
+
const fileMap = await fetcher(repo, `v${resolvedVersion}`, token);
|
|
105
|
+
const normalized = normalizeFileMap(fileMap);
|
|
106
|
+
const scoped = filterFileMapByTargets(normalized, resolvedTargets);
|
|
107
|
+
|
|
108
|
+
// Plan writes
|
|
109
|
+
const plannedWrites = [];
|
|
110
|
+
for (const [relKey, content] of Object.entries(scoped)) {
|
|
111
|
+
const absPath = resolveConsumerPath(consumerCwd, relKey);
|
|
112
|
+
plannedWrites.push({ relKey, absPath, content });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (dryRun) {
|
|
116
|
+
return {
|
|
117
|
+
dryRun: true,
|
|
118
|
+
resolvedVersion,
|
|
119
|
+
targets: resolvedTargets,
|
|
120
|
+
plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
|
|
121
|
+
files: plannedWrites.map(({ relKey }) => relKey),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Pre-flight collision check (so we fail before any writes)
|
|
126
|
+
for (const { absPath, relKey } of plannedWrites) {
|
|
127
|
+
if (existsSync(absPath) && !force) {
|
|
128
|
+
const fs = await import('node:fs');
|
|
129
|
+
const existing = fs.readFileSync(absPath, 'utf8');
|
|
130
|
+
if (!hasMarker(existing)) {
|
|
131
|
+
const err = new Error(
|
|
132
|
+
`refusing to overwrite unmanaged file at ${relKey} ` +
|
|
133
|
+
`(${absPath}). Delete/rename it or pass --force to overwrite.`,
|
|
134
|
+
);
|
|
135
|
+
err.exitCode = 1;
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const { absPath, relKey, content } of plannedWrites) {
|
|
142
|
+
writeManagedFile({ absPath, relKey, content, force: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
writeLockfile(consumerCwd, {
|
|
146
|
+
source,
|
|
147
|
+
range,
|
|
148
|
+
resolvedVersion,
|
|
149
|
+
targets: [...resolvedTargets].sort(),
|
|
150
|
+
lastUpdated: new Date().toISOString(),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
resolvedVersion,
|
|
155
|
+
targets: [...resolvedTargets].sort(),
|
|
156
|
+
plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
|
|
157
|
+
files: plannedWrites.map(({ relKey }) => relKey),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `status` subcommand. Informational, exit 0.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { readLockfile } from '../lockfile.mjs';
|
|
9
|
+
import { resolveRange } from '../range.mjs';
|
|
10
|
+
|
|
11
|
+
const TARGET_DIRS = {
|
|
12
|
+
claude: ['.claude', 'agents'],
|
|
13
|
+
cursor: ['.cursor', 'rules'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function countTargetFiles(consumerCwd, target) {
|
|
17
|
+
const segs = TARGET_DIRS[target];
|
|
18
|
+
if (!segs) return 0;
|
|
19
|
+
// Count files recursively under <target-root>/
|
|
20
|
+
const root = path.join(consumerCwd, segs[0]);
|
|
21
|
+
if (!existsSync(root)) return 0;
|
|
22
|
+
let n = 0;
|
|
23
|
+
const stack = [root];
|
|
24
|
+
while (stack.length) {
|
|
25
|
+
const dir = stack.pop();
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
} catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
const full = path.join(dir, entry.name);
|
|
34
|
+
if (entry.isDirectory()) stack.push(full);
|
|
35
|
+
else if (entry.isFile()) n += 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runStatus(consumerCwd, opts = {}) {
|
|
42
|
+
const { availableTags } = opts;
|
|
43
|
+
|
|
44
|
+
const lock = readLockfile(consumerCwd);
|
|
45
|
+
|
|
46
|
+
let latestAvailable = null;
|
|
47
|
+
if (Array.isArray(availableTags) && availableTags.length > 0) {
|
|
48
|
+
try {
|
|
49
|
+
latestAvailable = resolveRange(availableTags, lock.range);
|
|
50
|
+
} catch {
|
|
51
|
+
latestAvailable = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fileCounts = {};
|
|
56
|
+
for (const t of lock.targets) {
|
|
57
|
+
fileCounts[t] = countTargetFiles(consumerCwd, t);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
range: lock.range,
|
|
62
|
+
resolvedVersion: lock.resolvedVersion,
|
|
63
|
+
latestAvailable,
|
|
64
|
+
latest: latestAvailable,
|
|
65
|
+
targets: lock.targets,
|
|
66
|
+
source: lock.source,
|
|
67
|
+
fileCounts,
|
|
68
|
+
managedFiles: fileCounts,
|
|
69
|
+
counts: fileCounts,
|
|
70
|
+
};
|
|
71
|
+
}
|