@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.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `update` subcommand.
3
+ *
4
+ * Reads the lockfile, resolves latest matching tag, fetches that tag's dist,
5
+ * and rewrites managed files for the recorded targets.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+
10
+ import { readLockfile, writeLockfile } from '../lockfile.mjs';
11
+ import { resolveRange } from '../range.mjs';
12
+ import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writer.mjs';
13
+ import { hasMarker } from '../marker.mjs';
14
+
15
+ function filterFileMapByTargets(fileMap, targets) {
16
+ const out = {};
17
+ for (const [key, content] of Object.entries(fileMap)) {
18
+ const prefix = key.split('/')[0];
19
+ if (targets.includes(prefix)) {
20
+ out[key] = content;
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+
26
+ export async function runUpdate(consumerCwd, opts = {}) {
27
+ const { force = false, dryRun = false, token, fetcher, availableTags, repo = 'dalzoubi/dev-agents' } = opts;
28
+
29
+ if (typeof fetcher !== 'function') {
30
+ const err = new Error('runUpdate requires an injected fetcher');
31
+ err.exitCode = 2;
32
+ throw err;
33
+ }
34
+ if (!Array.isArray(availableTags)) {
35
+ const err = new Error('runUpdate requires availableTags');
36
+ err.exitCode = 2;
37
+ throw err;
38
+ }
39
+
40
+ const lock = readLockfile(consumerCwd);
41
+
42
+ const targetVersion = resolveRange(availableTags, lock.range);
43
+
44
+ if (targetVersion === lock.resolvedVersion) {
45
+ return {
46
+ upToDate: true,
47
+ resolvedVersion: lock.resolvedVersion,
48
+ message: `already up to date at v${lock.resolvedVersion}`,
49
+ };
50
+ }
51
+
52
+ const fileMap = await fetcher(repo, `v${targetVersion}`, token);
53
+ const normalized = normalizeFileMap(fileMap);
54
+ const scoped = filterFileMapByTargets(normalized, lock.targets);
55
+
56
+ const plannedWrites = [];
57
+ for (const [relKey, content] of Object.entries(scoped)) {
58
+ const absPath = resolveConsumerPath(consumerCwd, relKey);
59
+ plannedWrites.push({ relKey, absPath, content });
60
+ }
61
+
62
+ if (dryRun) {
63
+ return {
64
+ dryRun: true,
65
+ resolvedVersion: targetVersion,
66
+ previousVersion: lock.resolvedVersion,
67
+ files: plannedWrites.map(({ relKey }) => relKey),
68
+ };
69
+ }
70
+
71
+ // Collision check
72
+ for (const { absPath, relKey } of plannedWrites) {
73
+ if (existsSync(absPath) && !force) {
74
+ const existing = readFileSync(absPath, 'utf8');
75
+ if (!hasMarker(existing)) {
76
+ const err = new Error(
77
+ `refusing to overwrite unmanaged file at ${relKey} ` +
78
+ `(${absPath}). The marker is missing — pass --force to overwrite.`,
79
+ );
80
+ err.exitCode = 1;
81
+ throw err;
82
+ }
83
+ }
84
+ }
85
+
86
+ for (const { absPath, relKey, content } of plannedWrites) {
87
+ writeManagedFile({ absPath, relKey, content, force: true });
88
+ }
89
+
90
+ writeLockfile(consumerCwd, {
91
+ source: lock.source,
92
+ range: lock.range,
93
+ resolvedVersion: targetVersion,
94
+ targets: lock.targets,
95
+ lastUpdated: new Date().toISOString(),
96
+ });
97
+
98
+ return {
99
+ resolvedVersion: targetVersion,
100
+ previousVersion: lock.resolvedVersion,
101
+ files: plannedWrites.map(({ relKey }) => relKey),
102
+ };
103
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Fetcher abstraction.
3
+ *
4
+ * A fetcher is an async function:
5
+ * async (repo, tag, token) => FileMap
6
+ *
7
+ * where FileMap is { [relativePath: string]: string content }.
8
+ * Relative paths use forward slashes and start with the target prefix
9
+ * ("claude/..." or "cursor/...").
10
+ *
11
+ * Both `defaultGithubFetcher` and `defaultGithubTagLister` accept an optional
12
+ * trailing options object `{ httpClient, tarExtractor }` purely as a test
13
+ * injection seam. Production callers do not need to pass it; sane defaults
14
+ * (`globalThis.fetch`, a `tar`-package extractor) are used.
15
+ */
16
+
17
+ import { createHash } from 'node:crypto';
18
+ import semver from 'semver';
19
+
20
+ export const DEFAULT_REPO = 'dalzoubi/dev-agents';
21
+
22
+ /**
23
+ * Error code attached to errors thrown by the stub default fetcher/tag-lister
24
+ * placeholder. Real network errors thrown by the implementations below MUST
25
+ * NOT use this code — it is reserved for genuinely-unimplemented stubs.
26
+ */
27
+ export const STUB_NOT_IMPLEMENTED = 'STUB_NOT_IMPLEMENTED';
28
+
29
+ const GITHUB_API = 'https://api.github.com';
30
+ const STRICT_SEMVER_TAG = /^v\d+\.\d+\.\d+$/;
31
+
32
+ const AUTH_HINT =
33
+ 'set GITHUB_TOKEN or run `gh auth login`';
34
+
35
+ /**
36
+ * Builds a generic, actionable error with `exitCode = 2`.
37
+ * Never embeds the token or raw upstream body.
38
+ */
39
+ function makeError(message) {
40
+ const err = new Error(message);
41
+ err.exitCode = 2;
42
+ return err;
43
+ }
44
+
45
+ function authError(status) {
46
+ return makeError(
47
+ `authentication failed (HTTP ${status}) — ${AUTH_HINT}`,
48
+ );
49
+ }
50
+
51
+ function notFoundError(repo, tag) {
52
+ return makeError(`release v${tag} not found in ${repo}`);
53
+ }
54
+
55
+ function genericHttpError(status, context) {
56
+ return makeError(
57
+ `GitHub API request failed (HTTP ${status}) while ${context}`,
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Builds the standard request init object for GitHub API JSON endpoints.
63
+ */
64
+ function jsonInit(token) {
65
+ const headers = {
66
+ Accept: 'application/vnd.github+json',
67
+ 'X-GitHub-Api-Version': '2022-11-28',
68
+ };
69
+ if (token) {
70
+ headers.Authorization = `Bearer ${token}`;
71
+ }
72
+ return { headers };
73
+ }
74
+
75
+ /**
76
+ * Builds the standard request init object for asset binary downloads.
77
+ */
78
+ function assetInit(token) {
79
+ const headers = {
80
+ Accept: 'application/octet-stream',
81
+ 'X-GitHub-Api-Version': '2022-11-28',
82
+ };
83
+ if (token) {
84
+ headers.Authorization = `Bearer ${token}`;
85
+ }
86
+ return { headers };
87
+ }
88
+
89
+ /**
90
+ * Builds the request init for the second hop after a redirect from the GitHub
91
+ * asset endpoint. The Location target is typically a pre-signed S3 URL with
92
+ * its own query-string auth; forwarding GitHub's Bearer token causes S3 to
93
+ * reject the request, so we strip Authorization entirely.
94
+ */
95
+ function redirectInit() {
96
+ return {
97
+ headers: {
98
+ Accept: 'application/octet-stream',
99
+ },
100
+ };
101
+ }
102
+
103
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
104
+ const MAX_REDIRECTS = 5;
105
+
106
+ /**
107
+ * Downloads a release asset, manually following redirects without forwarding
108
+ * the Authorization header to the redirect target. Returns a successful
109
+ * response (`ok === true`); throws a generic CLI error otherwise.
110
+ */
111
+ async function downloadAsset(httpClient, url, token, context) {
112
+ // First hop: GitHub asset URL with Bearer token, manual redirect.
113
+ const init = assetInit(token);
114
+ init.redirect = 'manual';
115
+ let res = await httpClient(url, init);
116
+
117
+ let hops = 0;
118
+ while (REDIRECT_STATUSES.has(res.status)) {
119
+ if (hops >= MAX_REDIRECTS) {
120
+ throw makeError(`too many redirects while ${context}`);
121
+ }
122
+ const location = res.headers?.get?.('location') ?? null;
123
+ if (!location) {
124
+ throw makeError(`redirect missing Location header while ${context}`);
125
+ }
126
+ // Subsequent hops: NO Authorization header.
127
+ const nextInit = redirectInit();
128
+ nextInit.redirect = 'manual';
129
+ res = await httpClient(location, nextInit);
130
+ hops += 1;
131
+ }
132
+
133
+ if (!res.ok) {
134
+ if (res.status === 401 || res.status === 403) {
135
+ throw authError(res.status);
136
+ }
137
+ throw genericHttpError(res.status, context);
138
+ }
139
+ return res;
140
+ }
141
+
142
+ /**
143
+ * Default httpClient: thin wrapper over `globalThis.fetch`.
144
+ * Network/transport failures are translated into generic CLI errors with
145
+ * `exitCode = 2` and no token leakage.
146
+ */
147
+ async function defaultHttpClient(url, init) {
148
+ if (typeof globalThis.fetch !== 'function') {
149
+ throw makeError(
150
+ 'global fetch is unavailable — Node.js 20 or newer is required',
151
+ );
152
+ }
153
+ try {
154
+ return await globalThis.fetch(url, init);
155
+ } catch (_cause) {
156
+ throw makeError('network request to GitHub failed');
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Default tarExtractor: gunzips a tarball ArrayBuffer and returns a FileMap
162
+ * `{ [path]: utf8Content }`. Directory entries are skipped.
163
+ *
164
+ * Uses the `tar` npm package (added as a runtime dep). Imported lazily so the
165
+ * module loads even if `tar` isn't installed in environments that always
166
+ * inject their own extractor (e.g. tests).
167
+ */
168
+ export async function defaultTarExtractor(buffer) {
169
+ let tar;
170
+ try {
171
+ tar = await import('tar');
172
+ } catch (_cause) {
173
+ throw makeError(
174
+ 'tar extraction is unavailable — install the `tar` npm package',
175
+ );
176
+ }
177
+
178
+ const buf = Buffer.from(buffer);
179
+ const entries = {};
180
+
181
+ await new Promise((resolve, reject) => {
182
+ const parser = new tar.Parser({ gzip: true });
183
+ parser.on('entry', (entry) => {
184
+ // Skip directories.
185
+ if (entry.type === 'Directory' || entry.path.endsWith('/')) {
186
+ entry.resume();
187
+ return;
188
+ }
189
+ const chunks = [];
190
+ entry.on('data', (chunk) => chunks.push(chunk));
191
+ entry.on('end', () => {
192
+ entries[entry.path] = Buffer.concat(chunks).toString('utf8');
193
+ });
194
+ entry.on('error', reject);
195
+ });
196
+ parser.on('end', resolve);
197
+ parser.on('error', reject);
198
+ parser.end(buf);
199
+ });
200
+
201
+ return entries;
202
+ }
203
+
204
+ /**
205
+ * Lists release tags from a GitHub repo. Returns bare semver strings
206
+ * (no leading "v"), filtered to strict `MAJOR.MINOR.PATCH` form, with
207
+ * drafts and prereleases excluded, sorted descending by semver.
208
+ *
209
+ * @param {string} repo "owner/name"
210
+ * @param {string} token GitHub token (Bearer auth)
211
+ * @param {{ httpClient?: Function }} [opts]
212
+ * @returns {Promise<string[]>}
213
+ */
214
+ export async function defaultGithubTagLister(repo, token, opts = {}) {
215
+ const httpClient = opts.httpClient ?? defaultHttpClient;
216
+ const url = `${GITHUB_API}/repos/${repo}/releases`;
217
+
218
+ const res = await httpClient(url, jsonInit(token));
219
+
220
+ if (!res.ok) {
221
+ if (res.status === 401 || res.status === 403) {
222
+ throw authError(res.status);
223
+ }
224
+ throw genericHttpError(res.status, `listing releases for ${repo}`);
225
+ }
226
+
227
+ let body;
228
+ try {
229
+ body = await res.json();
230
+ } catch (_cause) {
231
+ throw makeError(
232
+ `failed to parse GitHub releases response for ${repo}`,
233
+ );
234
+ }
235
+
236
+ if (!Array.isArray(body)) {
237
+ return [];
238
+ }
239
+
240
+ const bare = body
241
+ .filter((r) => r && !r.draft && !r.prerelease)
242
+ .map((r) => r.tag_name)
243
+ .filter((t) => typeof t === 'string' && STRICT_SEMVER_TAG.test(t))
244
+ .map((t) => t.slice(1));
245
+
246
+ bare.sort((a, b) => semver.rcompare(a, b));
247
+ return bare;
248
+ }
249
+
250
+ /**
251
+ * Fetches a release's `dist-v<tag>.tar.gz` asset, optionally verifies the
252
+ * sidecar sha256, extracts it, strips the `dist/` prefix, and returns a
253
+ * FileMap.
254
+ *
255
+ * @param {string} repo "owner/name"
256
+ * @param {string} tag bare semver e.g. "1.0.0"
257
+ * @param {string} token GitHub token
258
+ * @param {{ httpClient?: Function, tarExtractor?: Function }} [opts]
259
+ * @returns {Promise<Record<string, string>>}
260
+ */
261
+ export async function defaultGithubFetcher(repo, tag, token, opts = {}) {
262
+ const httpClient = opts.httpClient ?? defaultHttpClient;
263
+ const tarExtractor = opts.tarExtractor ?? defaultTarExtractor;
264
+
265
+ const releaseUrl = `${GITHUB_API}/repos/${repo}/releases/tags/v${tag}`;
266
+ const releaseRes = await httpClient(releaseUrl, jsonInit(token));
267
+
268
+ if (!releaseRes.ok) {
269
+ if (releaseRes.status === 401 || releaseRes.status === 403) {
270
+ throw authError(releaseRes.status);
271
+ }
272
+ if (releaseRes.status === 404) {
273
+ throw notFoundError(repo, tag);
274
+ }
275
+ throw genericHttpError(
276
+ releaseRes.status,
277
+ `fetching release v${tag} from ${repo}`,
278
+ );
279
+ }
280
+
281
+ let release;
282
+ try {
283
+ release = await releaseRes.json();
284
+ } catch (_cause) {
285
+ throw makeError(
286
+ `failed to parse release metadata for v${tag} in ${repo}`,
287
+ );
288
+ }
289
+
290
+ const assets = Array.isArray(release?.assets) ? release.assets : [];
291
+ const tarballName = `dist-v${tag}.tar.gz`;
292
+ const sidecarName = `${tarballName}.sha256`;
293
+
294
+ const tarballAsset = assets.find((a) => a && a.name === tarballName);
295
+ if (!tarballAsset) {
296
+ throw makeError(
297
+ `asset '${tarballName}' not found on release v${tag}`,
298
+ );
299
+ }
300
+ const sidecarAsset = assets.find((a) => a && a.name === sidecarName);
301
+
302
+ // Download the tarball (manual redirect to avoid forwarding the GitHub
303
+ // Authorization header to S3 — S3 rejects it and has its own signed query auth).
304
+ const tarRes = await downloadAsset(
305
+ httpClient,
306
+ tarballAsset.url,
307
+ token,
308
+ `downloading asset '${tarballName}'`,
309
+ );
310
+ const tarBuffer = await tarRes.arrayBuffer();
311
+
312
+ // If a sidecar is present, verify the sha256.
313
+ if (sidecarAsset) {
314
+ const sidecarRes = await downloadAsset(
315
+ httpClient,
316
+ sidecarAsset.url,
317
+ token,
318
+ `downloading sha256 sidecar '${sidecarName}'`,
319
+ );
320
+ const sidecarBuf = await sidecarRes.arrayBuffer();
321
+ const sidecarText = Buffer.from(sidecarBuf).toString('utf8').trim();
322
+ // sha256sum format: "<hex> <filename>" — take the leading hex token.
323
+ const expectedHash = sidecarText.split(/\s+/)[0]?.toLowerCase() ?? '';
324
+
325
+ const actualHash = createHash('sha256')
326
+ .update(Buffer.from(tarBuffer))
327
+ .digest('hex')
328
+ .toLowerCase();
329
+
330
+ if (!expectedHash || expectedHash !== actualHash) {
331
+ throw makeError(`sha256 mismatch for ${tarballName}`);
332
+ }
333
+ }
334
+
335
+ // Extract and rewrite paths.
336
+ let entries;
337
+ try {
338
+ entries = await tarExtractor(tarBuffer);
339
+ } catch (_cause) {
340
+ throw makeError(
341
+ `failed to extract '${tarballName}'`,
342
+ );
343
+ }
344
+
345
+ const fileMap = {};
346
+ for (const [path, content] of Object.entries(entries ?? {})) {
347
+ if (typeof path !== 'string') continue;
348
+ if (path.endsWith('/')) continue; // directory entry
349
+ if (!path.startsWith('dist/')) continue;
350
+ const rewritten = path.slice('dist/'.length);
351
+ if (!rewritten) continue; // would be the bare "dist/" entry
352
+ fileMap[rewritten] = content;
353
+ }
354
+
355
+ return fileMap;
356
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Public programmatic entry point for @dalzoubi/dev-agents-sync.
3
+ */
4
+
5
+ export { readLockfile, writeLockfile, validateLockfile } from './lockfile.mjs';
6
+ export { hasMarker, buildMarker, extractMarkerVersion } from './marker.mjs';
7
+ export { resolveRange } from './range.mjs';
8
+ export { resolveToken } from './auth.mjs';
9
+ export { resolveConsumerPath } from './writer.mjs';
10
+ export { runInit } from './commands/init.mjs';
11
+ export { runUpdate } from './commands/update.mjs';
12
+ export { runStatus } from './commands/status.mjs';
13
+ export { runCheck } from './commands/check.mjs';
14
+ export { runDiff } from './commands/diff.mjs';
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Lockfile read/write/validate.
3
+ *
4
+ * The lockfile lives at <consumerCwd>/.dev-agents-sync.json.
5
+ * Output is byte-deterministic given the same inputs.
6
+ * The auth token is NEVER persisted into the lockfile.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export const LOCKFILE_NAME = '.dev-agents-sync.json';
13
+ export const DEFAULT_SOURCE = 'github:dalzoubi/dev-agents';
14
+ export const SCHEMA_URL =
15
+ 'https://raw.githubusercontent.com/dalzoubi/dev-agents/v1/schema/lockfile.schema.json';
16
+
17
+ const VALID_TARGETS = new Set(['claude', 'cursor']);
18
+
19
+ // Field order is load-bearing for determinism.
20
+ const FIELD_ORDER = [
21
+ '$schema',
22
+ 'source',
23
+ 'range',
24
+ 'resolvedVersion',
25
+ 'targets',
26
+ 'lastUpdated',
27
+ ];
28
+
29
+ export function lockfilePath(consumerCwd) {
30
+ return path.join(consumerCwd, LOCKFILE_NAME);
31
+ }
32
+
33
+ class LockfileError extends Error {
34
+ constructor(message, { exitCode = 1, cause } = {}) {
35
+ super(message);
36
+ this.name = 'LockfileError';
37
+ this.exitCode = exitCode;
38
+ if (cause) this.cause = cause;
39
+ }
40
+ }
41
+
42
+ export function validateLockfile(lock) {
43
+ if (lock === null || lock === undefined || typeof lock !== 'object') {
44
+ throw new LockfileError('invalid lockfile: expected an object');
45
+ }
46
+
47
+ if (typeof lock.source !== 'string' || lock.source.length === 0) {
48
+ throw new LockfileError('invalid lockfile: missing or invalid `source`');
49
+ }
50
+ if (typeof lock.range !== 'string' || lock.range.length === 0) {
51
+ throw new LockfileError('invalid lockfile: missing or invalid `range`');
52
+ }
53
+ if (typeof lock.resolvedVersion !== 'string' || lock.resolvedVersion.length === 0) {
54
+ throw new LockfileError(
55
+ 'invalid lockfile: missing or invalid `resolvedVersion`',
56
+ );
57
+ }
58
+ if (!Array.isArray(lock.targets)) {
59
+ throw new LockfileError('invalid lockfile: `targets` must be an array');
60
+ }
61
+ if (lock.targets.length === 0) {
62
+ throw new LockfileError('invalid lockfile: `targets` must not be empty');
63
+ }
64
+ for (const t of lock.targets) {
65
+ if (!VALID_TARGETS.has(t)) {
66
+ throw new LockfileError(
67
+ `invalid lockfile: unknown value in \`targets\`: ${JSON.stringify(t)} ` +
68
+ `(allowed: ${[...VALID_TARGETS].join(', ')})`,
69
+ );
70
+ }
71
+ }
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Returns the canonical, deterministic JSON serialization of a lockfile.
77
+ * Fields are emitted in a stable order; targets are sorted; trailing newline appended.
78
+ */
79
+ export function serializeLockfile(lock) {
80
+ const out = {};
81
+ // Always include $schema first (default if missing)
82
+ if (lock.$schema || lock.$schema === undefined) {
83
+ out.$schema = lock.$schema || SCHEMA_URL;
84
+ }
85
+ for (const key of FIELD_ORDER) {
86
+ if (key === '$schema') continue;
87
+ if (lock[key] === undefined) continue;
88
+ if (key === 'targets') {
89
+ out.targets = [...lock.targets].sort();
90
+ } else {
91
+ out[key] = lock[key];
92
+ }
93
+ }
94
+ return JSON.stringify(out, null, 2) + '\n';
95
+ }
96
+
97
+ /**
98
+ * Writes a lockfile to <consumerCwd>/.dev-agents-sync.json.
99
+ * The optional `_options` arg is accepted but never persisted (privacy invariant:
100
+ * tokens or other extra context must not land on disk).
101
+ */
102
+ export function writeLockfile(consumerCwd, lock, _options) {
103
+ const normalized = {
104
+ $schema: lock.$schema || SCHEMA_URL,
105
+ source: lock.source ?? DEFAULT_SOURCE,
106
+ range: lock.range,
107
+ resolvedVersion: lock.resolvedVersion,
108
+ targets: lock.targets,
109
+ lastUpdated: lock.lastUpdated,
110
+ };
111
+ validateLockfile(normalized);
112
+ const json = serializeLockfile(normalized);
113
+ writeFileSync(lockfilePath(consumerCwd), json, 'utf8');
114
+ return normalized;
115
+ }
116
+
117
+ export function readLockfile(consumerCwd) {
118
+ const file = lockfilePath(consumerCwd);
119
+ let raw;
120
+ try {
121
+ raw = readFileSync(file, 'utf8');
122
+ } catch (err) {
123
+ if (err && err.code === 'ENOENT') {
124
+ throw new LockfileError(
125
+ `no lockfile found at ${file} — run \`dev-agents-sync init\` first`,
126
+ { exitCode: 1, cause: err },
127
+ );
128
+ }
129
+ throw new LockfileError(`could not read lockfile at ${file}`, {
130
+ exitCode: 2,
131
+ cause: err,
132
+ });
133
+ }
134
+
135
+ let parsed;
136
+ try {
137
+ parsed = JSON.parse(raw);
138
+ } catch (err) {
139
+ throw new LockfileError(
140
+ `invalid JSON in lockfile at ${file}: could not parse`,
141
+ { exitCode: 1, cause: err },
142
+ );
143
+ }
144
+
145
+ validateLockfile(parsed);
146
+ return parsed;
147
+ }
package/src/marker.mjs ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Managed-file marker.
3
+ *
4
+ * Format: <!-- managed-by: dev-agents-sync vX.Y.Z -->
5
+ *
6
+ * Detection: the marker is the first non-empty body line, where "body" means
7
+ * - the entire file when there is no YAML frontmatter
8
+ * - the content after the closing `---` of the frontmatter, otherwise
9
+ */
10
+
11
+ const MARKER_RE = /^<!--\s*managed-by:\s*dev-agents-sync\s+v([0-9]+\.[0-9]+\.[0-9]+(?:[-+][\w.-]+)?)\s*-->\s*$/;
12
+
13
+ export function buildMarker(version) {
14
+ return `<!-- managed-by: dev-agents-sync v${version} -->`;
15
+ }
16
+
17
+ /**
18
+ * Returns the body of the file with frontmatter stripped (if any).
19
+ * If the file starts with `---` on its first line and a closing `---` is
20
+ * found later, everything between (inclusive) is removed.
21
+ */
22
+ function stripFrontmatter(content) {
23
+ if (!content) return '';
24
+ const lines = content.split(/\r?\n/);
25
+ if (lines[0] !== '---') {
26
+ return content;
27
+ }
28
+ // Find closing `---`
29
+ for (let i = 1; i < lines.length; i++) {
30
+ if (lines[i] === '---') {
31
+ return lines.slice(i + 1).join('\n');
32
+ }
33
+ }
34
+ // Unterminated frontmatter — treat the whole thing as body
35
+ return content;
36
+ }
37
+
38
+ function firstNonEmptyLine(text) {
39
+ const lines = text.split(/\r?\n/);
40
+ for (const line of lines) {
41
+ if (line.trim().length > 0) return line;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export function hasMarker(content) {
47
+ if (!content) return false;
48
+ const body = stripFrontmatter(content);
49
+ const first = firstNonEmptyLine(body);
50
+ if (first === null) return false;
51
+ return MARKER_RE.test(first);
52
+ }
53
+
54
+ export function extractMarkerVersion(content) {
55
+ if (!content) return null;
56
+ const body = stripFrontmatter(content);
57
+ const first = firstNonEmptyLine(body);
58
+ if (first === null) return null;
59
+ const m = first.match(MARKER_RE);
60
+ return m ? m[1] : null;
61
+ }