@stonepandastudio/cairn 0.4.1 → 0.4.2

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/lib/init.js +48 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -170,4 +170,4 @@ schema.json JSON Schema for cairn.config.json
170
170
  test/run.js dependency-free test runner
171
171
  ```
172
172
 
173
- No runtime dependencies. Node >= 18. `npm test` runs 54 tests.
173
+ No runtime dependencies. Node >= 18. `npm test` runs 56 tests.
package/lib/init.js CHANGED
@@ -10,22 +10,25 @@ const {
10
10
  record,
11
11
  writeManifest,
12
12
  manifestPath,
13
+ hashContent,
13
14
  } = require('./manifest');
14
15
  const { listProviders } = require('./tracker');
15
16
  const { makePaint } = require('./paint');
16
17
 
17
18
  // `cairn init` — make a repo cairn-managed.
18
19
  //
19
- // It writes exactly three things:
20
+ // It writes:
20
21
  // cairn.config.json hand-editable from here on; cairn never rewrites it
22
+ // except with --force
21
23
  // _cairn/scripts/jira.js vendored shim, tracked in the manifest
22
24
  // _cairn/manifest.json the record of what was written
23
25
  //
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.
26
+ // Plus, for every copied client the repo still carries (jira.js / youtrack.js /
27
+ // task-tracker.js under ai/scripts/), that file is replaced by the same shim
28
+ // replacing rather than deleting because dozens of prose references name the path
29
+ // and the shim reads the same argv. A file already holding the shim is skipped; a
30
+ // file hand-edited away from what cairn wrote is kept and reported unless
31
+ // --force-shims. cairn never invents one of these paths in a repo that lacked it.
29
32
 
30
33
  const SHIM_SOURCE = 'shims/jira.js';
31
34
  const SHIM_TARGET = '_cairn/scripts/jira.js';
@@ -55,6 +58,8 @@ Usage: cairn init [options]
55
58
  (only when the repo's app reads JIRA_*/YOUTRACK_* itself)
56
59
  --no-shim do not vendor or replace the ai/scripts/*.js client shims
57
60
  --force overwrite an existing cairn.config.json
61
+ --force-shims also replace an ai/scripts/*.js shim that was hand-edited
62
+ since cairn wrote it (kept untouched otherwise)
58
63
  --dry-run print what would be written, write nothing
59
64
  `;
60
65
 
@@ -69,6 +74,7 @@ function parseArgs(argv) {
69
74
  envPrefix: '',
70
75
  shim: true,
71
76
  force: false,
77
+ forceShims: false,
72
78
  dryRun: false,
73
79
  color: process.stdout.isTTY,
74
80
  };
@@ -83,6 +89,7 @@ function parseArgs(argv) {
83
89
  else if (a === '--env-prefix') args.envPrefix = argv[++i];
84
90
  else if (a === '--no-shim') args.shim = false;
85
91
  else if (a === '--force') args.force = true;
92
+ else if (a === '--force-shims') args.forceShims = args.force = true;
86
93
  else if (a === '--dry-run') args.dryRun = true;
87
94
  else if (a === '--no-color') args.color = false;
88
95
  else if (a === '-h' || a === '--help') args.help = true;
@@ -148,29 +155,52 @@ function main(argv = process.argv.slice(2)) {
148
155
  return 2;
149
156
  }
150
157
 
158
+ const manifest = readManifest(repoPath) || emptyManifest();
159
+
151
160
  const planned = [];
161
+ const kept = [];
152
162
  const config = buildConfig(args);
153
163
  planned.push([CONFIG_NAME, JSON.stringify(config, null, 2) + '\n', null]);
154
164
 
155
165
  const shim = readTemplate(SHIM_SOURCE);
156
166
  if (args.shim) {
157
167
  planned.push([SHIM_TARGET, shim, SHIM_SOURCE]);
158
- // Only replace a legacy path if a copied client is actually there creating
159
- // one in a repo that never had it would invent a path nothing references.
168
+ // Replace a legacy client only where one is actually present. A file already
169
+ // holding the shim is left alone; one hand-edited away from what cairn wrote
170
+ // is kept unless --force — never silently overwrite an edit.
160
171
  for (const target of LEGACY_SHIM_TARGETS) {
161
- if (fs.existsSync(path.join(repoPath, target))) {
162
- planned.push([target, shim, SHIM_SOURCE]);
172
+ const abs = path.join(repoPath, target);
173
+ if (!fs.existsSync(abs)) continue;
174
+ const current = fs.readFileSync(abs, 'utf8');
175
+ const entry = manifest.files[target];
176
+
177
+ // Already the shim and already tracked — nothing to do. If it holds the
178
+ // shim but isn't in the manifest (someone copied it by hand), fall through
179
+ // so this run records it.
180
+ if (current === shim && entry) continue;
181
+
182
+ const modified = entry && hashContent(current) !== entry.hash;
183
+ if (modified && !args.forceShims) {
184
+ kept.push(target);
185
+ continue;
163
186
  }
187
+ const note =
188
+ current === shim ? 'recording existing shim' : entry ? null : `replacing ${current.split('\n').length} lines`;
189
+ planned.push([target, shim, SHIM_SOURCE, note]);
164
190
  }
165
191
  }
166
192
 
167
193
  if (args.dryRun) {
168
194
  console.log(paint.bold(`cairn init --dry-run ${repoPath}`));
169
- for (const [rel] of planned) console.log(` would write ${rel}`);
195
+ for (const [rel, , , note] of planned) {
196
+ console.log(` would write ${rel}${note ? paint.dim(` (${note})`) : ''}`);
197
+ }
198
+ for (const rel of kept) {
199
+ console.log(paint.yellow(` would keep ${rel} (hand-edited — pass --force-shims to replace)`));
200
+ }
170
201
  return 0;
171
202
  }
172
203
 
173
- const manifest = readManifest(repoPath) || emptyManifest();
174
204
  for (const [rel, content, source] of planned) {
175
205
  const abs = path.join(repoPath, rel);
176
206
  fs.mkdirSync(path.dirname(abs), { recursive: true });
@@ -182,10 +212,14 @@ function main(argv = process.argv.slice(2)) {
182
212
  writeManifest(repoPath, manifest);
183
213
 
184
214
  console.log(paint.bold(`cairn init ${repoPath}`));
185
- for (const [rel, , source] of planned) {
186
- console.log(` ${paint.green('wrote')} ${rel}${source ? paint.dim(` (${source})`) : ''}`);
215
+ for (const [rel, , source, note] of planned) {
216
+ const tag = note || source;
217
+ console.log(` ${paint.green('wrote')} ${rel}${tag ? paint.dim(` (${tag})`) : ''}`);
187
218
  }
188
219
  console.log(` ${paint.green('wrote')} ${path.relative(repoPath, manifestPath(repoPath)).replace(/\\/g, '/')}`);
220
+ for (const rel of kept) {
221
+ console.log(paint.yellow(` kept ${rel} (hand-edited since cairn wrote it — pass --force-shims to replace)`));
222
+ }
189
223
 
190
224
  const gitignore = path.join(repoPath, '.gitignore');
191
225
  const hasIgnore = fs.existsSync(gitignore) && /^_cairn\b/m.test(fs.readFileSync(gitignore, 'utf8'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonepandastudio/cairn",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Shared AI workflow scaffolding for Stone Panda repos — issue tracker client and drift doctor.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {