@stonepandastudio/cairn 0.3.1 → 0.4.1
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/README.md +12 -9
- package/lib/config.js +22 -1
- package/lib/doctor/index.js +8 -5
- package/lib/init.js +12 -8
- package/lib/tracker/index.js +17 -2
- package/lib/tracker/jira-server.js +6 -5
- package/lib/tracker/youtrack.js +9 -5
- package/package.json +1 -1
- package/schema.json +12 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ agreeing.
|
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npm i -D @stonepandastudio/cairn
|
|
15
|
-
npx cairn init --stack
|
|
15
|
+
npx cairn init --stack nestjs,typeorm --tracker jira-server --project-key MYPROJ --story-type Task
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
## What v1 does
|
|
@@ -79,7 +79,9 @@ Credentials come from the repo's `.env` — `JIRA_BASE_URL` / `JIRA_USER` /
|
|
|
79
79
|
`JIRA_PASSWORD` for `jira-server` (Basic auth: Jira Server 8.5.1 predates PATs), or
|
|
80
80
|
`YOUTRACK_URL` / `YOUTRACK_TOKEN` / `YOUTRACK_PROJECT` for `youtrack` (Bearer token,
|
|
81
81
|
admin-read scope for the project and stage-bundle lookups). Anything already in the
|
|
82
|
-
environment wins over the file.
|
|
82
|
+
environment wins over the file. If the repo's own app reads those same names, set
|
|
83
|
+
`tracker.envPrefix` (`cairn init --env-prefix CAIRN_`) so cairn reads
|
|
84
|
+
`CAIRN_YOUTRACK_TOKEN` instead — or point `tracker.env` at a separate file.
|
|
83
85
|
|
|
84
86
|
### Legacy command names
|
|
85
87
|
|
|
@@ -134,16 +136,17 @@ overwriting a hand edit is the destructive outcome.
|
|
|
134
136
|
|
|
135
137
|
### Workspace config (`repos.json`)
|
|
136
138
|
|
|
137
|
-
- `repos[]` — name, path, stack
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
- `repos[]` — name, path, stack (`"angular"` or a list `["nestjs", "typeorm"]` —
|
|
140
|
+
framework first). Per-repo settings live in each repo's own `cairn.config.json`;
|
|
141
|
+
the inline `vars` blocks are a fallback used only until a repo has been through
|
|
142
|
+
`cairn init`, so migration can happen one repo at a time.
|
|
140
143
|
- `pathAliases` — local → canonical. When one repo names a stub `execute-tests.md`
|
|
141
144
|
where the others use `execute-test-plan.md`, aliasing folds them onto the cohort's
|
|
142
145
|
names instead of reporting one `MISSING` and one `STRANDED` for the same file.
|
|
143
146
|
- `shared[]` — glob rules. `cohort: "all"` compares every repo; `cohort: "stack"`
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
never skipped on that basis.
|
|
147
|
+
groups by the first stack entry (all `nestjs` repos together, whatever the ORM).
|
|
148
|
+
`requires: { tracker: true }` skips repos with no tracker. A repo with no config
|
|
149
|
+
has an *unknown* tracker, not an absent one, and is never skipped on that basis.
|
|
147
150
|
- Anything not matched by a rule — `ai/contexts/`, `ai/progress/`, `ai/tasks/`,
|
|
148
151
|
`DATABASE_SCHEMA.md` — is per-project by design and never inspected.
|
|
149
152
|
`ai/INITIAL_PROMPT.md` is explicitly excluded: variants at ~0% shared content make
|
|
@@ -167,4 +170,4 @@ schema.json JSON Schema for cairn.config.json
|
|
|
167
170
|
test/run.js dependency-free test runner
|
|
168
171
|
```
|
|
169
172
|
|
|
170
|
-
No runtime dependencies. Node >= 18. `npm test` runs
|
|
173
|
+
No runtime dependencies. Node >= 18. `npm test` runs 54 tests.
|
package/lib/config.js
CHANGED
|
@@ -13,7 +13,7 @@ const CAIRN_DIR = '_cairn';
|
|
|
13
13
|
// generated directory from slowly acquiring hand-maintained state.
|
|
14
14
|
|
|
15
15
|
const DEFAULTS = {
|
|
16
|
-
stack: 'unknown',
|
|
16
|
+
stack: ['unknown'],
|
|
17
17
|
tracker: { provider: 'none' },
|
|
18
18
|
agents: {},
|
|
19
19
|
vars: {},
|
|
@@ -21,6 +21,21 @@ const DEFAULTS = {
|
|
|
21
21
|
|
|
22
22
|
class ConfigError extends Error {}
|
|
23
23
|
|
|
24
|
+
// `stack` is a list — the framework first, then composable add-ons
|
|
25
|
+
// (`["nestjs", "typeorm"]`). A bare string or a comma-separated string is
|
|
26
|
+
// accepted and normalized. The doctor's `cohort: "stack"` groups by the first
|
|
27
|
+
// entry; the v2 renderer layers a preset per entry, in order.
|
|
28
|
+
function normalizeStack(value) {
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
const list = value.map((s) => String(s).trim()).filter(Boolean);
|
|
31
|
+
return list.length ? list : ['unknown'];
|
|
32
|
+
}
|
|
33
|
+
if (typeof value === 'string' && value.trim()) {
|
|
34
|
+
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
return ['unknown'];
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
function configPath(repoPath) {
|
|
25
40
|
return path.join(repoPath, CONFIG_NAME);
|
|
26
41
|
}
|
|
@@ -69,6 +84,10 @@ function normalizeTracker(tracker) {
|
|
|
69
84
|
t.issueTypes = { story: 'Story', subtask: 'Sub-task', ...(t.issueTypes || {}) };
|
|
70
85
|
t.statuses = { todo: 'To Do', inProgress: 'In Progress', done: 'Done', ...(t.statuses || {}) };
|
|
71
86
|
t.env = t.env || '.env';
|
|
87
|
+
// Prepended to every credential var name the provider reads — set it when the
|
|
88
|
+
// repo's app talks to the same tracker with its own credentials and `JIRA_*` /
|
|
89
|
+
// `YOUTRACK_*` would collide. Empty by default.
|
|
90
|
+
t.envPrefix = t.envPrefix || '';
|
|
72
91
|
return t;
|
|
73
92
|
}
|
|
74
93
|
|
|
@@ -110,6 +129,7 @@ function loadRepoConfig(repoPath, { fallbackVars = null, required = false } = {}
|
|
|
110
129
|
const cfg = {
|
|
111
130
|
...DEFAULTS,
|
|
112
131
|
...raw,
|
|
132
|
+
stack: normalizeStack(raw.stack),
|
|
113
133
|
tracker: normalizeTracker(raw.tracker),
|
|
114
134
|
agents: raw.agents || {},
|
|
115
135
|
vars: raw.vars || fallbackVars || {},
|
|
@@ -142,5 +162,6 @@ module.exports = {
|
|
|
142
162
|
findRepoRoot,
|
|
143
163
|
loadEnv,
|
|
144
164
|
loadRepoConfig,
|
|
165
|
+
normalizeStack,
|
|
145
166
|
parseEnv,
|
|
146
167
|
};
|
package/lib/doctor/index.js
CHANGED
|
@@ -20,7 +20,7 @@ const { matchRule, readFile } = require('./scan');
|
|
|
20
20
|
const { buildNormalizer } = require('./normalize');
|
|
21
21
|
const diff = require('./diff');
|
|
22
22
|
const { makePaint, stripAnsi, table } = require('../paint');
|
|
23
|
-
const { loadRepoConfig } = require('../config');
|
|
23
|
+
const { loadRepoConfig, normalizeStack } = require('../config');
|
|
24
24
|
const { readManifest, statusFor } = require('../manifest');
|
|
25
25
|
|
|
26
26
|
function hash(s) {
|
|
@@ -55,7 +55,8 @@ function resolveRepos(config, configDir) {
|
|
|
55
55
|
return {
|
|
56
56
|
name: entry.name,
|
|
57
57
|
path: repoPath.replace(/\\/g, '/'),
|
|
58
|
-
|
|
58
|
+
// A list — framework first, then add-ons. `cohort: "stack"` groups by [0].
|
|
59
|
+
stack: normalizeStack(entry.stack || repoConfig.stack),
|
|
59
60
|
vars: { ...(repoConfig.vars || {}), ...(entry.vars || {}) },
|
|
60
61
|
tracker: repoConfig.tracker,
|
|
61
62
|
managed: !repoConfig.unmanaged,
|
|
@@ -72,7 +73,9 @@ function cohortGroups(rule, repos) {
|
|
|
72
73
|
if (rule.cohort === 'stack') {
|
|
73
74
|
const byStack = new Map();
|
|
74
75
|
for (const repo of repos) {
|
|
75
|
-
|
|
76
|
+
// Compare at framework granularity — the shared contracts live there, and
|
|
77
|
+
// ORM/add-on differences are resolved by composing presets, not by cohort.
|
|
78
|
+
const key = (repo.stack && repo.stack[0]) || 'unknown';
|
|
76
79
|
if (!byStack.has(key)) byStack.set(key, []);
|
|
77
80
|
byStack.get(key).push(repo);
|
|
78
81
|
}
|
|
@@ -94,7 +97,7 @@ function repoMeetsRequirements(repo, rule) {
|
|
|
94
97
|
if (req.tracker === true && repo.managed && (!repo.tracker || repo.tracker.provider === 'none')) {
|
|
95
98
|
return false;
|
|
96
99
|
}
|
|
97
|
-
if (req.stack && repo.stack
|
|
100
|
+
if (req.stack && !(repo.stack || []).includes(req.stack)) return false;
|
|
98
101
|
return true;
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -514,4 +517,4 @@ function main(argv = process.argv.slice(2)) {
|
|
|
514
517
|
return 0;
|
|
515
518
|
}
|
|
516
519
|
|
|
517
|
-
module.exports = { main, analyze, analyzeManaged, resolveRepos, render, HELP, stripAnsi };
|
|
520
|
+
module.exports = { main, analyze, analyzeManaged, resolveRepos, cohortGroups, render, HELP, stripAnsi };
|
package/lib/init.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
-
const { CONFIG_NAME, configPath, cairnDir } = require('./config');
|
|
6
|
+
const { CONFIG_NAME, configPath, cairnDir, normalizeStack } = require('./config');
|
|
7
7
|
const {
|
|
8
8
|
emptyManifest,
|
|
9
9
|
readManifest,
|
|
@@ -46,11 +46,13 @@ const HELP = `cairn init — make a repo cairn-managed
|
|
|
46
46
|
Usage: cairn init [options]
|
|
47
47
|
|
|
48
48
|
--repo <path> repo to initialise (default: cwd)
|
|
49
|
-
--stack <
|
|
49
|
+
--stack <names> comma-separated, framework first: nestjs,typeorm | angular | cli
|
|
50
50
|
--tracker <provider> ${listProviders().join(' | ')} (default: none)
|
|
51
51
|
--project-key <KEY> tracker project key, e.g. PROOF
|
|
52
|
-
--story-type <name> parent issue type (default: Story)
|
|
53
|
-
--subtask-type <name> child issue type (default: Sub-task)
|
|
52
|
+
--story-type <name> parent issue type (default: Story, jira-server only)
|
|
53
|
+
--subtask-type <name> child issue type (default: Sub-task, jira-server only)
|
|
54
|
+
--env-prefix <PFX> prefix for the tracker credential vars, e.g. CAIRN_
|
|
55
|
+
(only when the repo's app reads JIRA_*/YOUTRACK_* itself)
|
|
54
56
|
--no-shim do not vendor or replace the ai/scripts/*.js client shims
|
|
55
57
|
--force overwrite an existing cairn.config.json
|
|
56
58
|
--dry-run print what would be written, write nothing
|
|
@@ -64,6 +66,7 @@ function parseArgs(argv) {
|
|
|
64
66
|
projectKey: null,
|
|
65
67
|
storyType: 'Story',
|
|
66
68
|
subtaskType: 'Sub-task',
|
|
69
|
+
envPrefix: '',
|
|
67
70
|
shim: true,
|
|
68
71
|
force: false,
|
|
69
72
|
dryRun: false,
|
|
@@ -77,6 +80,7 @@ function parseArgs(argv) {
|
|
|
77
80
|
else if (a === '--project-key') args.projectKey = argv[++i];
|
|
78
81
|
else if (a === '--story-type') args.storyType = argv[++i];
|
|
79
82
|
else if (a === '--subtask-type') args.subtaskType = argv[++i];
|
|
83
|
+
else if (a === '--env-prefix') args.envPrefix = argv[++i];
|
|
80
84
|
else if (a === '--no-shim') args.shim = false;
|
|
81
85
|
else if (a === '--force') args.force = true;
|
|
82
86
|
else if (a === '--dry-run') args.dryRun = true;
|
|
@@ -94,12 +98,13 @@ function buildConfig(args) {
|
|
|
94
98
|
const cfg = {
|
|
95
99
|
$schema: `./node_modules/${require('../package.json').name}/schema.json`,
|
|
96
100
|
cairn: require('../package.json').version,
|
|
97
|
-
stack: args.stack,
|
|
101
|
+
stack: normalizeStack(args.stack),
|
|
98
102
|
tracker: { provider: args.tracker },
|
|
99
103
|
};
|
|
100
104
|
if (args.tracker !== 'none') {
|
|
101
105
|
cfg.tracker.projectKey = args.projectKey;
|
|
102
106
|
cfg.tracker.env = '.env';
|
|
107
|
+
if (args.envPrefix) cfg.tracker.envPrefix = args.envPrefix;
|
|
103
108
|
// issueTypes is a Jira concept — YouTrack has no Task/Sub-task split.
|
|
104
109
|
if (args.tracker === 'jira-server') {
|
|
105
110
|
cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
|
|
@@ -190,9 +195,8 @@ function main(argv = process.argv.slice(2)) {
|
|
|
190
195
|
);
|
|
191
196
|
}
|
|
192
197
|
if (args.tracker !== 'none') {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
);
|
|
198
|
+
const envVars = require('./tracker').providerEnv(args.tracker, args.envPrefix);
|
|
199
|
+
console.log(paint.dim(`\n Next: ensure .env has ${envVars.join(', ')}, then run`));
|
|
196
200
|
console.log(paint.dim(` npx cairn tracker list-statuses ${args.projectKey}`));
|
|
197
201
|
}
|
|
198
202
|
return 0;
|
package/lib/tracker/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { loadEnv, loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
|
|
4
|
-
const
|
|
5
|
-
const
|
|
4
|
+
const jiraServer = require('./jira-server');
|
|
5
|
+
const youtrack = require('./youtrack');
|
|
6
|
+
const { createJiraServer, TrackerError, NoTransitionError } = jiraServer;
|
|
7
|
+
const { createYouTrack } = youtrack;
|
|
6
8
|
const { createNone } = require('./none');
|
|
7
9
|
|
|
8
10
|
// Provider registry. Adding Linear or GitHub Issues later means adding one entry
|
|
@@ -18,6 +20,18 @@ function listProviders() {
|
|
|
18
20
|
return Object.keys(PROVIDERS);
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// The .env variables a provider reads. `cairn init` prints these as the next step,
|
|
24
|
+
// so a youtrack repo is not told to set JIRA_* and vice versa.
|
|
25
|
+
const CREDENTIAL_ENV = {
|
|
26
|
+
'jira-server': jiraServer.CREDENTIAL_ENV,
|
|
27
|
+
youtrack: youtrack.CREDENTIAL_ENV,
|
|
28
|
+
none: [],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function providerEnv(name, prefix = '') {
|
|
32
|
+
return (CREDENTIAL_ENV[name] || []).map((v) => prefix + v);
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
// Build a tracker straight from a tracker config block plus an env bag.
|
|
22
36
|
function createTracker(trackerConfig = {}, env = process.env) {
|
|
23
37
|
const name = trackerConfig.provider || 'none';
|
|
@@ -48,6 +62,7 @@ module.exports = {
|
|
|
48
62
|
createTracker,
|
|
49
63
|
trackerForRepo,
|
|
50
64
|
listProviders,
|
|
65
|
+
providerEnv,
|
|
51
66
|
TrackerError,
|
|
52
67
|
NoTransitionError,
|
|
53
68
|
};
|
|
@@ -38,14 +38,15 @@ class NoTransitionError extends TrackerError {
|
|
|
38
38
|
const REQUIRED_ENV = ['JIRA_BASE_URL', 'JIRA_USER', 'JIRA_PASSWORD'];
|
|
39
39
|
|
|
40
40
|
function createJiraServer(config = {}, env = process.env) {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
41
|
+
const pfx = config.envPrefix || '';
|
|
42
|
+
const baseUrl = (config.baseUrl || env[pfx + 'JIRA_BASE_URL'] || '').replace(/\/+$/, '');
|
|
43
|
+
const user = config.user || env[pfx + 'JIRA_USER'];
|
|
44
|
+
const password = config.password || env[pfx + 'JIRA_PASSWORD'];
|
|
44
45
|
const projectKey = config.projectKey;
|
|
45
46
|
const issueTypes = { story: 'Story', subtask: 'Sub-task', ...(config.issueTypes || {}) };
|
|
46
47
|
|
|
47
48
|
function assertConfig() {
|
|
48
|
-
const missing = REQUIRED_ENV.filter((k, i) => ![baseUrl, user, password][i]);
|
|
49
|
+
const missing = REQUIRED_ENV.filter((k, i) => ![baseUrl, user, password][i]).map((k) => pfx + k);
|
|
49
50
|
if (missing.length) {
|
|
50
51
|
throw new TrackerError(
|
|
51
52
|
`Missing Jira credentials: ${missing.join(', ')}. Set them in the repo's .env or the environment.`,
|
|
@@ -322,4 +323,4 @@ function createJiraServer(config = {}, env = process.env) {
|
|
|
322
323
|
};
|
|
323
324
|
}
|
|
324
325
|
|
|
325
|
-
module.exports = { createJiraServer, TrackerError, NoTransitionError };
|
|
326
|
+
module.exports = { createJiraServer, TrackerError, NoTransitionError, CREDENTIAL_ENV: REQUIRED_ENV };
|
package/lib/tracker/youtrack.js
CHANGED
|
@@ -21,16 +21,20 @@
|
|
|
21
21
|
const { TrackerError, NoTransitionError } = require('./jira-server');
|
|
22
22
|
|
|
23
23
|
const REQUIRED_ENV = ['YOUTRACK_URL', 'YOUTRACK_TOKEN'];
|
|
24
|
+
// What `cairn init` tells the user to put in .env — includes the project, which
|
|
25
|
+
// the guard treats as optional only because it can also come from cairn.config.json.
|
|
26
|
+
const CREDENTIAL_ENV = ['YOUTRACK_URL', 'YOUTRACK_TOKEN', 'YOUTRACK_PROJECT'];
|
|
24
27
|
const STAGE_FIELD = 'Stage';
|
|
25
28
|
const ASSIGNEE_FIELD = 'Assignee';
|
|
26
29
|
|
|
27
30
|
function createYouTrack(config = {}, env = process.env) {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
+
const pfx = config.envPrefix || '';
|
|
32
|
+
const baseUrl = (config.baseUrl || env[pfx + 'YOUTRACK_URL'] || '').replace(/\/+$/, '');
|
|
33
|
+
const token = config.token || env[pfx + 'YOUTRACK_TOKEN'];
|
|
34
|
+
const projectKey = config.projectKey || env[pfx + 'YOUTRACK_PROJECT'];
|
|
31
35
|
|
|
32
36
|
function assertConfig() {
|
|
33
|
-
const missing = REQUIRED_ENV.filter((k, i) => ![baseUrl, token][i]);
|
|
37
|
+
const missing = REQUIRED_ENV.filter((k, i) => ![baseUrl, token][i]).map((k) => pfx + k);
|
|
34
38
|
if (missing.length) {
|
|
35
39
|
throw new TrackerError(
|
|
36
40
|
`Missing YouTrack credentials: ${missing.join(', ')}. Set them in the repo's .env or the environment.`,
|
|
@@ -314,4 +318,4 @@ function createYouTrack(config = {}, env = process.env) {
|
|
|
314
318
|
};
|
|
315
319
|
}
|
|
316
320
|
|
|
317
|
-
module.exports = { createYouTrack, TrackerError, NoTransitionError };
|
|
321
|
+
module.exports = { createYouTrack, TrackerError, NoTransitionError, CREDENTIAL_ENV };
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
"description": "Version of @stonepandastudio/cairn this repo is pinned to."
|
|
13
13
|
},
|
|
14
14
|
"stack": {
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
15
|
+
"description": "Framework first, then composable add-ons. `cohort: \"stack\"` in the doctor groups by the first entry; the v2 renderer layers one preset per entry, in order. A bare or comma-separated string is also accepted.",
|
|
16
|
+
"anyOf": [
|
|
17
|
+
{ "type": "string" },
|
|
18
|
+
{ "type": "array", "items": { "type": "string" }, "minItems": 1 }
|
|
19
|
+
],
|
|
20
|
+
"examples": ["angular", ["nestjs", "typeorm"], "cli"]
|
|
18
21
|
},
|
|
19
22
|
"tracker": {
|
|
20
23
|
"type": "object",
|
|
@@ -48,6 +51,12 @@
|
|
|
48
51
|
"type": "string",
|
|
49
52
|
"default": ".env",
|
|
50
53
|
"description": "Path, relative to the repo root, of the file holding tracker credentials."
|
|
54
|
+
},
|
|
55
|
+
"envPrefix": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"default": "",
|
|
58
|
+
"description": "Prepended to every credential var the provider reads (e.g. \"CAIRN_\" → CAIRN_YOUTRACK_TOKEN). Set it only when the repo's own app reads JIRA_*/YOUTRACK_* and the names would collide.",
|
|
59
|
+
"examples": ["CAIRN_"]
|
|
51
60
|
}
|
|
52
61
|
}
|
|
53
62
|
},
|