@stonepandastudio/cairn 0.2.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/lib/init.js ADDED
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { CONFIG_NAME, configPath, cairnDir } = require('./config');
7
+ const {
8
+ emptyManifest,
9
+ readManifest,
10
+ record,
11
+ writeManifest,
12
+ manifestPath,
13
+ } = require('./manifest');
14
+ const { listProviders } = require('./tracker');
15
+ const { makePaint } = require('./paint');
16
+
17
+ // `cairn init` — make a repo cairn-managed.
18
+ //
19
+ // It writes exactly three things:
20
+ // cairn.config.json hand-editable from here on; cairn never rewrites it
21
+ // _cairn/scripts/jira.js vendored shim, tracked in the manifest
22
+ // _cairn/manifest.json the record of what was written
23
+ //
24
+ // Plus, when the repo still carries a copied ai/scripts/jira.js, that file is
25
+ // replaced by a shim too. Replacing rather than deleting is deliberate: the five
26
+ // repos have roughly forty prose references to that path, and breaking them all
27
+ // in the same release that swaps the Jira client would make any failure
28
+ // impossible to attribute.
29
+
30
+ const SHIM_SOURCE = 'shims/jira.js';
31
+ const SHIM_TARGET = '_cairn/scripts/jira.js';
32
+ const LEGACY_SHIM_TARGET = 'ai/scripts/jira.js';
33
+
34
+ const HELP = `cairn init — make a repo cairn-managed
35
+
36
+ Usage: cairn init [options]
37
+
38
+ --repo <path> repo to initialise (default: cwd)
39
+ --stack <name> backend | frontend | cli | ... (default: unknown)
40
+ --tracker <provider> ${listProviders().join(' | ')} (default: none)
41
+ --project-key <KEY> tracker project key, e.g. PROOF
42
+ --story-type <name> parent issue type (default: Story)
43
+ --subtask-type <name> child issue type (default: Sub-task)
44
+ --no-shim do not vendor the ai/scripts/jira.js compatibility shim
45
+ --force overwrite an existing cairn.config.json
46
+ --dry-run print what would be written, write nothing
47
+ `;
48
+
49
+ function parseArgs(argv) {
50
+ const args = {
51
+ repo: process.cwd(),
52
+ stack: 'unknown',
53
+ tracker: 'none',
54
+ projectKey: null,
55
+ storyType: 'Story',
56
+ subtaskType: 'Sub-task',
57
+ shim: true,
58
+ force: false,
59
+ dryRun: false,
60
+ color: process.stdout.isTTY,
61
+ };
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a === '--repo') args.repo = argv[++i];
65
+ else if (a === '--stack') args.stack = argv[++i];
66
+ else if (a === '--tracker') args.tracker = argv[++i];
67
+ else if (a === '--project-key') args.projectKey = argv[++i];
68
+ else if (a === '--story-type') args.storyType = argv[++i];
69
+ else if (a === '--subtask-type') args.subtaskType = argv[++i];
70
+ else if (a === '--no-shim') args.shim = false;
71
+ else if (a === '--force') args.force = true;
72
+ else if (a === '--dry-run') args.dryRun = true;
73
+ else if (a === '--no-color') args.color = false;
74
+ else if (a === '-h' || a === '--help') args.help = true;
75
+ else {
76
+ console.error(`Unknown argument: ${a}`);
77
+ return { error: 2 };
78
+ }
79
+ }
80
+ return args;
81
+ }
82
+
83
+ function buildConfig(args) {
84
+ const cfg = {
85
+ $schema: `./node_modules/${require('../package.json').name}/schema.json`,
86
+ cairn: require('../package.json').version,
87
+ stack: args.stack,
88
+ tracker: { provider: args.tracker },
89
+ };
90
+ if (args.tracker !== 'none') {
91
+ cfg.tracker.projectKey = args.projectKey;
92
+ cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
93
+ cfg.tracker.env = '.env';
94
+ }
95
+ cfg.agents = {};
96
+ return cfg;
97
+ }
98
+
99
+ function readTemplate(rel) {
100
+ return fs.readFileSync(path.join(__dirname, '..', 'templates', rel), 'utf8');
101
+ }
102
+
103
+ function main(argv = process.argv.slice(2)) {
104
+ const args = parseArgs(argv);
105
+ if (args.error) return args.error;
106
+ if (args.help) {
107
+ process.stdout.write(HELP);
108
+ return 0;
109
+ }
110
+
111
+ const paint = makePaint(args.color);
112
+ const repoPath = path.resolve(args.repo);
113
+
114
+ if (!fs.existsSync(repoPath)) {
115
+ console.error(`Repo not found: ${repoPath}`);
116
+ return 2;
117
+ }
118
+ if (!listProviders().includes(args.tracker)) {
119
+ console.error(`Unknown tracker "${args.tracker}". Available: ${listProviders().join(', ')}`);
120
+ return 2;
121
+ }
122
+ if (args.tracker !== 'none' && !args.projectKey) {
123
+ console.error(`--tracker ${args.tracker} requires --project-key`);
124
+ return 2;
125
+ }
126
+
127
+ const cfgFile = configPath(repoPath);
128
+ if (fs.existsSync(cfgFile) && !args.force) {
129
+ console.error(`${cfgFile} already exists — pass --force to overwrite`);
130
+ return 2;
131
+ }
132
+
133
+ const planned = [];
134
+ const config = buildConfig(args);
135
+ planned.push([CONFIG_NAME, JSON.stringify(config, null, 2) + '\n', null]);
136
+
137
+ const shim = readTemplate(SHIM_SOURCE);
138
+ if (args.shim) {
139
+ planned.push([SHIM_TARGET, shim, SHIM_SOURCE]);
140
+ // Only replace the legacy path if a copied client is actually there. Creating
141
+ // it in a repo that never had one would invent a path nothing references.
142
+ if (fs.existsSync(path.join(repoPath, LEGACY_SHIM_TARGET))) {
143
+ planned.push([LEGACY_SHIM_TARGET, shim, SHIM_SOURCE]);
144
+ }
145
+ }
146
+
147
+ if (args.dryRun) {
148
+ console.log(paint.bold(`cairn init --dry-run ${repoPath}`));
149
+ for (const [rel] of planned) console.log(` would write ${rel}`);
150
+ return 0;
151
+ }
152
+
153
+ const manifest = readManifest(repoPath) || emptyManifest();
154
+ for (const [rel, content, source] of planned) {
155
+ const abs = path.join(repoPath, rel);
156
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
157
+ fs.writeFileSync(abs, content, 'utf8');
158
+ // cairn.config.json is hand-editable, so it is deliberately not manifested —
159
+ // tracking it would report every legitimate edit as MODIFIED.
160
+ if (source) record(manifest, rel, source, content);
161
+ }
162
+ writeManifest(repoPath, manifest);
163
+
164
+ console.log(paint.bold(`cairn init ${repoPath}`));
165
+ for (const [rel, , source] of planned) {
166
+ console.log(` ${paint.green('wrote')} ${rel}${source ? paint.dim(` (${source})`) : ''}`);
167
+ }
168
+ console.log(` ${paint.green('wrote')} ${path.relative(repoPath, manifestPath(repoPath)).replace(/\\/g, '/')}`);
169
+
170
+ const gitignore = path.join(repoPath, '.gitignore');
171
+ const hasIgnore = fs.existsSync(gitignore) && /^_cairn\b/m.test(fs.readFileSync(gitignore, 'utf8'));
172
+ if (hasIgnore) {
173
+ console.log(
174
+ paint.yellow(` warning: _cairn/ is gitignored in this repo — generated files must be committed`),
175
+ );
176
+ }
177
+ if (args.tracker !== 'none') {
178
+ console.log(
179
+ paint.dim(`\n Next: ensure .env has JIRA_BASE_URL, JIRA_USER, JIRA_PASSWORD, then run`),
180
+ );
181
+ console.log(paint.dim(` npx cairn tracker list-statuses ${args.projectKey}`));
182
+ }
183
+ return 0;
184
+ }
185
+
186
+ module.exports = { main, buildConfig, HELP, SHIM_TARGET, LEGACY_SHIM_TARGET, cairnDir };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+
7
+ const { CAIRN_DIR, cairnDir } = require('./config');
8
+
9
+ const MANIFEST_NAME = 'manifest.json';
10
+ const MANIFEST_VERSION = 1;
11
+ const GENERATOR = require('../package.json').name;
12
+
13
+ // The manifest is what makes "generated" auditable. Without it, a vendored file
14
+ // that someone hand-edited is indistinguishable from one cairn wrote — which is
15
+ // exactly the failure mode that produced the drift this tool exists to report.
16
+ //
17
+ // Each entry records the template it came from and the hash of what cairn wrote.
18
+ // Comparing that to the hash on disk gives three states instead of one:
19
+ //
20
+ // MANAGED on-disk hash === written hash nothing to do
21
+ // MODIFIED on-disk hash !== written hash someone edited it by hand
22
+ // OUTDATED written hash !== current template hash cairn has newer content
23
+ // DELETED file is gone sync will restore it
24
+
25
+ function manifestPath(repoPath) {
26
+ return path.join(cairnDir(repoPath), MANIFEST_NAME);
27
+ }
28
+
29
+ // Normalize before hashing so a CRLF checkout doesn't read as a hand edit. This
30
+ // matters here specifically: these repos are Windows-side with mixed .gitattributes.
31
+ function hashContent(text) {
32
+ const normalized = String(text).replace(/^/, '').replace(/\r\n?/g, '\n');
33
+ return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 16);
34
+ }
35
+
36
+ function emptyManifest() {
37
+ return { version: MANIFEST_VERSION, generator: GENERATOR, files: {} };
38
+ }
39
+
40
+ function readManifest(repoPath) {
41
+ const file = manifestPath(repoPath);
42
+ if (!fs.existsSync(file)) return null;
43
+ try {
44
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
45
+ if (!parsed.files) parsed.files = {};
46
+ return parsed;
47
+ } catch (err) {
48
+ throw new Error(`${file}: invalid JSON — ${err.message}`);
49
+ }
50
+ }
51
+
52
+ function writeManifest(repoPath, manifest) {
53
+ const dir = cairnDir(repoPath);
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ const ordered = {
56
+ version: MANIFEST_VERSION,
57
+ generator: GENERATOR,
58
+ generatedAt: new Date().toISOString(),
59
+ files: Object.fromEntries(Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b))),
60
+ };
61
+ fs.writeFileSync(manifestPath(repoPath), JSON.stringify(ordered, null, 2) + '\n', 'utf8');
62
+ return ordered;
63
+ }
64
+
65
+ // Record that cairn wrote `content` to `relPath` from template `source`.
66
+ function record(manifest, relPath, source, content, extra = {}) {
67
+ manifest.files[relPath.replace(/\\/g, '/')] = {
68
+ source,
69
+ hash: hashContent(content),
70
+ ...extra,
71
+ };
72
+ return manifest;
73
+ }
74
+
75
+ // Compare every manifest entry against what is actually on disk. `templateHashes`
76
+ // is an optional { source -> hash } map; supply it to detect OUTDATED as well.
77
+ function statusFor(repoPath, manifest, templateHashes = null) {
78
+ const results = [];
79
+ for (const [rel, entry] of Object.entries(manifest.files)) {
80
+ const abs = path.join(repoPath, rel);
81
+ if (!fs.existsSync(abs)) {
82
+ results.push({ path: rel, source: entry.source, state: 'DELETED' });
83
+ continue;
84
+ }
85
+ const onDisk = hashContent(fs.readFileSync(abs, 'utf8'));
86
+ if (onDisk !== entry.hash) {
87
+ results.push({ path: rel, source: entry.source, state: 'MODIFIED' });
88
+ continue;
89
+ }
90
+ if (templateHashes && templateHashes[entry.source] && templateHashes[entry.source] !== entry.hash) {
91
+ results.push({ path: rel, source: entry.source, state: 'OUTDATED' });
92
+ continue;
93
+ }
94
+ results.push({ path: rel, source: entry.source, state: 'MANAGED' });
95
+ }
96
+ return results.sort((a, b) => a.path.localeCompare(b.path));
97
+ }
98
+
99
+ // Header stamped into every generated text file. `cairn doctor` reads the
100
+ // manifest rather than this comment, but the comment is what a human sees first.
101
+ function header(source, content, comment = 'html') {
102
+ const line = `cairn:generated source=${source} hash=${hashContent(content)} — edit cairn.config.json, not this file`;
103
+ if (comment === 'js') return `// ${line}\n`;
104
+ if (comment === 'hash') return `# ${line}\n`;
105
+ return `<!-- ${line} -->\n`;
106
+ }
107
+
108
+ module.exports = {
109
+ CAIRN_DIR,
110
+ MANIFEST_NAME,
111
+ MANIFEST_VERSION,
112
+ emptyManifest,
113
+ hashContent,
114
+ header,
115
+ manifestPath,
116
+ readManifest,
117
+ record,
118
+ statusFor,
119
+ writeManifest,
120
+ };
package/lib/paint.js ADDED
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ // Tiny ANSI helper. Every command takes the same --color / --no-color pair, so
4
+ // the enabled flag is decided once at the CLI boundary and threaded down.
5
+
6
+ function makePaint(enabled) {
7
+ const wrap = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : String(s));
8
+ return {
9
+ enabled,
10
+ bold: wrap(1),
11
+ dim: wrap(2),
12
+ red: wrap(31),
13
+ green: wrap(32),
14
+ yellow: wrap(33),
15
+ blue: wrap(34),
16
+ magenta: wrap(35),
17
+ cyan: wrap(36),
18
+ };
19
+ }
20
+
21
+ function stripAnsi(s) {
22
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '');
23
+ }
24
+
25
+ // Column-aligned table that measures visible width, not byte width, so coloured
26
+ // cells don't blow up the alignment.
27
+ function table(rows, headers, paint) {
28
+ const widths = headers.map((h, i) =>
29
+ Math.max(h.length, ...rows.map((r) => stripAnsi(r[i]).length)),
30
+ );
31
+ const line = (cells) =>
32
+ cells
33
+ .map((cell, i) => String(cell) + ' '.repeat(widths[i] - stripAnsi(cell).length))
34
+ .join(' ')
35
+ .trimEnd();
36
+ const out = [paint.bold(line(headers)), paint.dim(widths.map((w) => '─'.repeat(w)).join(' '))];
37
+ for (const row of rows) out.push(line(row));
38
+ return out.join('\n');
39
+ }
40
+
41
+ module.exports = { makePaint, stripAnsi, table };
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ const { trackerForRepo, TrackerError, NoTransitionError } = require('./index');
6
+ const { ConfigError } = require('../config');
7
+
8
+ // Command-line surface for the tracker.
9
+ //
10
+ // Every command name from the four replaced `ai/scripts/jira.js` copies is kept,
11
+ // including the ones that only existed in one repo (`create-story` in glossr,
12
+ // `create-task` in snap, `close-story` in glossr-frontend). They are aliases onto
13
+ // one implementation now, but the names stay because agent docs, command stubs
14
+ // and WORKFLOW.md across five repos reference them by string. Renaming them is a
15
+ // v2 concern, done in the same pass that regenerates those files.
16
+ //
17
+ // Output shapes are also preserved verbatim — `create-*` prints the bare key,
18
+ // `get` prints JSON, `set-status` prints `KEY -> Status` — because the agents
19
+ // read this output. Changing it would silently break prompts.
20
+
21
+ const ALIASES = {
22
+ 'create-task': 'create-issue',
23
+ 'create-story': 'create-issue',
24
+ 'create-subtask': 'create-subissue',
25
+ 'close-story': 'close',
26
+ 'close-task': 'close',
27
+ };
28
+
29
+ const USAGE = `cairn tracker <command> [...args]
30
+
31
+ list-projects
32
+ list-statuses [projectKey]
33
+ create-task | create-story <summary> [descriptionOrFile]
34
+ create-subtask <parentKey> <summary> [descriptionOrFile]
35
+ get <key>
36
+ set-fields <key> [--summary ..] [--description ..] [--description-file ..]
37
+ append-description <key> <textOrFile>
38
+ set-status <key> <statusName>
39
+ close-story <key> [statusName=Done]
40
+ set-assignee <key> <username>
41
+
42
+ --markup convert markdown input to Jira wiki markup before sending
43
+ --json print machine-readable output where the command supports it
44
+ `;
45
+
46
+ function parseFlags(args) {
47
+ const flags = {};
48
+ const rest = [];
49
+ for (let i = 0; i < args.length; i++) {
50
+ const arg = args[i];
51
+ if (arg.startsWith('--')) {
52
+ const key = arg.slice(2);
53
+ const next = args[i + 1];
54
+ if (next === undefined || next.startsWith('--')) {
55
+ flags[key] = true;
56
+ } else {
57
+ flags[key] = next;
58
+ i++;
59
+ }
60
+ } else {
61
+ rest.push(arg);
62
+ }
63
+ }
64
+ return { flags, rest };
65
+ }
66
+
67
+ // A value that names an existing file is read as one; otherwise it is the text.
68
+ // Inherited behaviour — the workflow passes description files by path.
69
+ function readMaybeFile(value) {
70
+ if (value === undefined || value === true) return undefined;
71
+ try {
72
+ if (fs.existsSync(value) && fs.statSync(value).isFile()) return fs.readFileSync(value, 'utf8');
73
+ } catch {
74
+ /* fall through to literal */
75
+ }
76
+ return value;
77
+ }
78
+
79
+ async function run(argv, { cwd = process.cwd(), out = console.log, err = console.error } = {}) {
80
+ const [rawCommand, ...args] = argv;
81
+ if (!rawCommand || rawCommand === '--help' || rawCommand === '-h') {
82
+ out(USAGE);
83
+ return 0;
84
+ }
85
+
86
+ const command = ALIASES[rawCommand] || rawCommand;
87
+ const { flags, rest } = parseFlags(args);
88
+
89
+ let tracker;
90
+ try {
91
+ ({ tracker } = trackerForRepo(cwd));
92
+ } catch (e) {
93
+ if (e instanceof ConfigError) {
94
+ err(e.message);
95
+ return 2;
96
+ }
97
+ throw e;
98
+ }
99
+
100
+ const markup = (text) => (flags.markup && text !== undefined ? tracker.toMarkup(text) : text);
101
+
102
+ try {
103
+ switch (command) {
104
+ case 'list-projects': {
105
+ const projects = await tracker.listProjects();
106
+ if (flags.json) out(JSON.stringify(projects, null, 2));
107
+ else for (const p of projects) out(`${p.key} - ${p.name}`);
108
+ return 0;
109
+ }
110
+
111
+ case 'list-statuses': {
112
+ const groups = await tracker.listStatuses(rest[0]);
113
+ if (flags.json) out(JSON.stringify(groups, null, 2));
114
+ else for (const g of groups) out(`${g.type}: ${g.statuses.join(', ')}`);
115
+ return 0;
116
+ }
117
+
118
+ case 'create-issue': {
119
+ const key = await tracker.createIssue({
120
+ summary: rest[0],
121
+ description: markup(readMaybeFile(rest[1])) || '',
122
+ type: flags.type,
123
+ });
124
+ if (key === null) {
125
+ err('tracker.provider is "none" — no issue created');
126
+ return 0;
127
+ }
128
+ out(key);
129
+ return 0;
130
+ }
131
+
132
+ case 'create-subissue': {
133
+ const key = await tracker.createSubIssue({
134
+ parentKey: rest[0],
135
+ summary: rest[1],
136
+ description: markup(readMaybeFile(rest[2])) || '',
137
+ type: flags.type,
138
+ });
139
+ if (key === null) {
140
+ err('tracker.provider is "none" — no sub-issue created');
141
+ return 0;
142
+ }
143
+ out(key);
144
+ return 0;
145
+ }
146
+
147
+ case 'get': {
148
+ const issue = await tracker.get(rest[0]);
149
+ out(JSON.stringify(issue, null, 2));
150
+ return 0;
151
+ }
152
+
153
+ case 'set-fields': {
154
+ const descArg = flags['description-file'] !== undefined ? flags['description-file'] : flags.description;
155
+ const fields = {};
156
+ if (flags.summary !== undefined && flags.summary !== true) fields.summary = flags.summary;
157
+ if (descArg !== undefined) fields.description = markup(readMaybeFile(descArg));
158
+ await tracker.setFields(rest[0], fields);
159
+ out(`${rest[0]} updated`);
160
+ return 0;
161
+ }
162
+
163
+ case 'append-description': {
164
+ await tracker.appendDescription(rest[0], markup(readMaybeFile(rest[1])));
165
+ out(`${rest[0]} description appended`);
166
+ return 0;
167
+ }
168
+
169
+ case 'set-status': {
170
+ const result = await tracker.setStatus(rest[0], rest[1]);
171
+ // Never print `KEY -> Status` for something that did not happen — the
172
+ // agents read this line as confirmation the board was actually moved.
173
+ if (result.skipped) err(`${result.key}: ${result.reason} — status not changed`);
174
+ else if (result.changed === false) out(`${result.key} already ${result.to}`);
175
+ else out(`${result.key} -> ${result.to}`);
176
+ return 0;
177
+ }
178
+
179
+ case 'close': {
180
+ const status = rest[1] || 'Done';
181
+ const result = await tracker.closeIssue(rest[0], status);
182
+ if (result.skipped) {
183
+ err(`${result.key}: ${result.reason} — nothing closed`);
184
+ return 0;
185
+ }
186
+ for (const r of result.results) {
187
+ if (r.ok) out(`${r.key} -> ${r.to}`);
188
+ else err(`${r.key}: ${r.reason}`);
189
+ }
190
+ if (result.failed) {
191
+ err(`${result.failed} issue(s) could not be moved to "${status}".`);
192
+ return 1;
193
+ }
194
+ out(`${result.key} and all subtasks are now "${status}".`);
195
+ return 0;
196
+ }
197
+
198
+ case 'set-assignee': {
199
+ const result = await tracker.setAssignee(rest[0], rest[1]);
200
+ out(`${result.key} assigned to ${result.assignee}`);
201
+ return 0;
202
+ }
203
+
204
+ default:
205
+ err(`Unknown command: ${rawCommand}`);
206
+ err(USAGE);
207
+ return 2;
208
+ }
209
+ } catch (e) {
210
+ // A missing transition means the issue is already at or past the target
211
+ // status on a re-run. Report it, but don't fail the workflow step.
212
+ if (e instanceof NoTransitionError) {
213
+ err(e.message);
214
+ if (e.available.length) err(` available: ${e.available.join(', ')}`);
215
+ return 0;
216
+ }
217
+ if (e instanceof TrackerError || e instanceof ConfigError) {
218
+ err(e.message);
219
+ return 1;
220
+ }
221
+ throw e;
222
+ }
223
+ }
224
+
225
+ // Entry point used by the vendored shim: `require('@stonepandastudio/cairn/tracker/cli')(args)`.
226
+ function main(argv = process.argv.slice(2)) {
227
+ run(argv)
228
+ .then((code) => {
229
+ if (code) process.exitCode = code;
230
+ })
231
+ .catch((e) => {
232
+ console.error(e);
233
+ process.exitCode = 1;
234
+ });
235
+ }
236
+
237
+ module.exports = main;
238
+ module.exports.run = run;
239
+ module.exports.parseFlags = parseFlags;
240
+ module.exports.USAGE = USAGE;
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { loadEnv, loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
4
+ const { createJiraServer, TrackerError, NoTransitionError } = require('./jira-server');
5
+ const { createNone } = require('./none');
6
+
7
+ // Provider registry. Adding Linear or GitHub Issues later means adding one entry
8
+ // here and one file next to jira-server.js — the workflow layer above only ever
9
+ // sees the shared interface.
10
+ const PROVIDERS = {
11
+ 'jira-server': createJiraServer,
12
+ none: createNone,
13
+ };
14
+
15
+ function listProviders() {
16
+ return Object.keys(PROVIDERS);
17
+ }
18
+
19
+ // Build a tracker straight from a tracker config block plus an env bag.
20
+ function createTracker(trackerConfig = {}, env = process.env) {
21
+ const name = trackerConfig.provider || 'none';
22
+ const factory = PROVIDERS[name];
23
+ if (!factory) {
24
+ throw new ConfigError(
25
+ `Unknown tracker provider "${name}". Available: ${listProviders().join(', ')}`,
26
+ );
27
+ }
28
+ return factory(trackerConfig, env);
29
+ }
30
+
31
+ // Build a tracker for a repo: find the repo root, read cairn.config.json, load
32
+ // its .env, then construct. This is the entry point the CLI and the shim use.
33
+ function trackerForRepo(startDir = process.cwd()) {
34
+ const repoPath = findRepoRoot(startDir);
35
+ if (!repoPath) {
36
+ throw new ConfigError(
37
+ `No cairn.config.json found in ${startDir} or any parent — run \`cairn init\` in the repo root`,
38
+ );
39
+ }
40
+ const config = loadRepoConfig(repoPath, { required: true });
41
+ loadEnv(repoPath, config.tracker.env);
42
+ return { tracker: createTracker(config.tracker), config, repoPath };
43
+ }
44
+
45
+ module.exports = {
46
+ createTracker,
47
+ trackerForRepo,
48
+ listProviders,
49
+ TrackerError,
50
+ NoTransitionError,
51
+ };