coreslopper 0.1.0 → 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.
@@ -0,0 +1,372 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { chmod, copyFile, mkdir, readFile, readdir, rm, rmdir, stat } from 'node:fs/promises';
5
+ import { dirname, join, posix, relative, resolve, sep } from 'node:path';
6
+ import { ValidationError } from '@coreslopper/core';
7
+ import { runSetup } from './tools.js';
8
+ import {
9
+ WORKSPACE_MARKER,
10
+ isWorkspace,
11
+ ownVersion,
12
+ runtimeDir as defaultRuntimeDir,
13
+ type WorkspaceMarker,
14
+ } from './workspace.js';
15
+
16
+ /** Directories that never belong to the shipped workflow. */
17
+ export const SKIPPED_DIRECTORIES = new Set([
18
+ '.git',
19
+ '.cache',
20
+ '.tools',
21
+ '.vitest',
22
+ '.venv',
23
+ '__pycache__',
24
+ '.pytest_cache',
25
+ 'node_modules',
26
+ 'dist',
27
+ 'runtime',
28
+ 'tmp',
29
+ ]);
30
+
31
+ /** Suffix for the upstream version of a file that was changed on both sides. */
32
+ export const CONFLICT_SUFFIX = '.coreslopper-new';
33
+
34
+ /** Files the workspace owns after init; update never touches them. */
35
+ const USER_OWNED_FILES = new Set(['.mcp.json', WORKSPACE_MARKER, 'GETTING_STARTED.md']);
36
+
37
+ /** Files that are regenerated locally and always follow upstream. */
38
+ const ALWAYS_UPSTREAM_FILES = new Set(['package-lock.json']);
39
+
40
+ /** Upstream files below these prefixes are added when missing but never overwritten or removed. */
41
+ const USER_OWNED_PREFIXES = ['projects/'];
42
+
43
+ export function isRuntimePath(path: string): boolean {
44
+ if (!path) return true;
45
+ const segments = path.split('/');
46
+ if (segments.some((segment) => SKIPPED_DIRECTORIES.has(segment))) return false;
47
+ if (segments.at(-1)?.endsWith(CONFLICT_SUFFIX)) return false;
48
+ if (path === '.env' || (path.startsWith('.env.') && path !== '.env.example')) return false;
49
+ return true;
50
+ }
51
+
52
+ function toPosix(path: string): string {
53
+ return sep === '/' ? path : path.split(sep).join(posix.sep);
54
+ }
55
+
56
+ /** List runtime files as POSIX paths relative to the runtime root. */
57
+ export async function listRuntimeFiles(runtime: string): Promise<string[]> {
58
+ const files: string[] = [];
59
+ async function walk(directory: string): Promise<void> {
60
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
61
+ const absolute = join(directory, entry.name);
62
+ const path = toPosix(relative(runtime, absolute));
63
+ if (!isRuntimePath(path)) continue;
64
+ if (entry.isDirectory()) await walk(absolute);
65
+ else if (entry.isFile()) files.push(path);
66
+ }
67
+ }
68
+ await walk(runtime);
69
+ return files.sort();
70
+ }
71
+
72
+ export async function hashFile(path: string): Promise<string> {
73
+ return createHash('sha256').update(await readFile(path)).digest('hex');
74
+ }
75
+
76
+ /** Hash every runtime file; the result is the baseline for the next update. */
77
+ export async function hashRuntime(runtime: string): Promise<Record<string, string>> {
78
+ const hashes: Record<string, string> = {};
79
+ for (const file of await listRuntimeFiles(runtime)) hashes[file] = await hashFile(join(runtime, file));
80
+ return hashes;
81
+ }
82
+
83
+ export function readMarker(target: string): WorkspaceMarker | undefined {
84
+ const path = join(target, WORKSPACE_MARKER);
85
+ if (!existsSync(path)) return undefined;
86
+ try {
87
+ return JSON.parse(readFileSync(path, 'utf8')) as WorkspaceMarker;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ export function writeMarkerFile(target: string, marker: WorkspaceMarker): void {
94
+ writeFileSync(join(target, WORKSPACE_MARKER), `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
95
+ }
96
+
97
+ /** Compare two `x.y.z[-pre]` versions; prerelease tags are ignored. */
98
+ export function compareVersions(a: string, b: string): number {
99
+ const parse = (value: string) => value.split('-')[0]!.split('.').map((part) => Number(part) || 0);
100
+ const left = parse(a);
101
+ const right = parse(b);
102
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
103
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
104
+ if (difference !== 0) return Math.sign(difference);
105
+ }
106
+ return 0;
107
+ }
108
+
109
+ export type UpdateAction =
110
+ | 'added'
111
+ | 'updated'
112
+ | 'removed'
113
+ | 'conflict'
114
+ | 'overwritten'
115
+ | 'kept-removed-upstream'
116
+ | 'skipped-deleted-locally';
117
+
118
+ export interface UpdateChange {
119
+ path: string;
120
+ action: UpdateAction;
121
+ }
122
+
123
+ export interface UpdateOptions {
124
+ targetDir: string;
125
+ runtimeDir?: string;
126
+ /** Overwrite locally changed files and allow a downgrade. */
127
+ force?: boolean;
128
+ dryRun?: boolean;
129
+ install?: boolean;
130
+ onProgress?: (message: string) => void;
131
+ }
132
+
133
+ export interface UpdateResult {
134
+ targetDir: string;
135
+ fromVersion?: string;
136
+ toVersion: string;
137
+ /** False when the workspace had no recorded baseline (created before `update` existed). */
138
+ hadBaseline: boolean;
139
+ changes: UpdateChange[];
140
+ /** Locally changed files that upstream did not touch. */
141
+ keptLocal: number;
142
+ dryRun: boolean;
143
+ installed: boolean;
144
+ uvSynced: boolean;
145
+ dirtyGit: boolean;
146
+ warnings: string[];
147
+ }
148
+
149
+ function resolveRuntime(runtimeDir?: string): string {
150
+ const runtime = runtimeDir ? resolve(runtimeDir) : defaultRuntimeDir();
151
+ if (!runtime || !existsSync(join(runtime, 'templates', 'project'))) {
152
+ throw new ValidationError('Runtime assets not found. Run "npx coreslopper@latest update" from the workspace.');
153
+ }
154
+ return runtime;
155
+ }
156
+
157
+ function runtimeVersion(runtime: string): string {
158
+ try {
159
+ const packageJson = JSON.parse(readFileSync(join(runtime, 'package.json'), 'utf8')) as { version?: string };
160
+ return packageJson.version ?? ownVersion();
161
+ } catch {
162
+ return ownVersion();
163
+ }
164
+ }
165
+
166
+ function gitIsDirty(target: string): boolean {
167
+ if (!existsSync(join(target, '.git'))) return false;
168
+ const result = spawnSync('git', ['status', '--porcelain'], { cwd: target, encoding: 'utf8' });
169
+ return result.status === 0 && result.stdout.trim().length > 0;
170
+ }
171
+
172
+ async function install(source: string, destination: string): Promise<void> {
173
+ await mkdir(dirname(destination), { recursive: true });
174
+ await copyFile(source, destination);
175
+ await chmod(destination, (await stat(source)).mode).catch(() => undefined);
176
+ }
177
+
178
+ /** Remove now-empty parent directories of a deleted file, up to the workspace root. */
179
+ async function pruneEmptyDirectories(target: string, file: string): Promise<void> {
180
+ let directory = dirname(join(target, file));
181
+ while (directory !== target && directory.startsWith(target)) {
182
+ if ((await readdir(directory)).length > 0) return;
183
+ await rmdir(directory);
184
+ directory = dirname(directory);
185
+ }
186
+ }
187
+
188
+ export async function updateWorkspace(options: UpdateOptions): Promise<UpdateResult> {
189
+ const target = resolve(options.targetDir);
190
+ const runtime = resolveRuntime(options.runtimeDir);
191
+
192
+ if (!isWorkspace(target)) {
193
+ throw new ValidationError(`No coreslopper workspace found at ${target}. Create one with "npx coreslopper init".`);
194
+ }
195
+ if (existsSync(join(target, 'scripts', 'build-runtime.mjs')) || existsSync(join(target, 'tsup.config.ts'))) {
196
+ throw new ValidationError('This is the coreslopper source repository. Update it with git, not "coreslopper update".');
197
+ }
198
+
199
+ const dirtyGit = gitIsDirty(target);
200
+ const marker = readMarker(target);
201
+ const fromVersion = marker?.runtimeVersion;
202
+ const toVersion = runtimeVersion(runtime);
203
+ if (fromVersion && compareVersions(toVersion, fromVersion) < 0 && !options.force) {
204
+ throw new ValidationError(
205
+ `The workspace runs coreslopper ${fromVersion}, newer than ${toVersion}. Use "npx coreslopper@latest update", or pass --force to downgrade.`,
206
+ );
207
+ }
208
+
209
+ const baseline = marker?.files;
210
+ const hadBaseline = baseline !== undefined;
211
+ const base = baseline ?? {};
212
+ const dryRun = Boolean(options.dryRun);
213
+ const changes: UpdateChange[] = [];
214
+ const nextFiles: Record<string, string> = {};
215
+ let keptLocal = 0;
216
+
217
+ options.onProgress?.(`Comparing the workspace with coreslopper ${toVersion}...`);
218
+ const upstream = await hashRuntime(runtime);
219
+
220
+ const record = (path: string, action: UpdateAction) => changes.push({ path, action });
221
+ const apply = async (path: string) => {
222
+ if (!dryRun) await install(join(runtime, path), join(target, path));
223
+ };
224
+
225
+ for (const [path, upstreamHash] of Object.entries(upstream)) {
226
+ nextFiles[path] = upstreamHash;
227
+ if (USER_OWNED_FILES.has(path)) continue;
228
+
229
+ const local = join(target, path);
230
+ const localExists = existsSync(local);
231
+ const baseHash = base[path];
232
+
233
+ if (!localExists) {
234
+ if (baseHash !== undefined && !ALWAYS_UPSTREAM_FILES.has(path)) {
235
+ // Deleted on purpose; mention it only when upstream changed the file.
236
+ if (upstreamHash !== baseHash) record(path, 'skipped-deleted-locally');
237
+ continue;
238
+ }
239
+ await apply(path);
240
+ record(path, 'added');
241
+ continue;
242
+ }
243
+
244
+ const localHash = await hashFile(local);
245
+ if (localHash === upstreamHash) continue;
246
+ if (USER_OWNED_PREFIXES.some((prefix) => path.startsWith(prefix))) continue;
247
+
248
+ if (ALWAYS_UPSTREAM_FILES.has(path) || localHash === baseHash) {
249
+ await apply(path);
250
+ record(path, 'updated');
251
+ continue;
252
+ }
253
+
254
+ if (baseHash !== undefined && upstreamHash === baseHash) {
255
+ // Changed locally, unchanged upstream: the local version wins.
256
+ keptLocal += 1;
257
+ continue;
258
+ }
259
+
260
+ // Changed on both sides, or no baseline to tell.
261
+ if (options.force) {
262
+ await apply(path);
263
+ record(path, 'overwritten');
264
+ } else {
265
+ if (!dryRun) await install(join(runtime, path), `${local}${CONFLICT_SUFFIX}`);
266
+ record(path, 'conflict');
267
+ }
268
+ }
269
+
270
+ for (const [path, baseHash] of Object.entries(base)) {
271
+ if (path in upstream || USER_OWNED_FILES.has(path)) continue;
272
+ if (USER_OWNED_PREFIXES.some((prefix) => path.startsWith(prefix))) continue;
273
+ const local = join(target, path);
274
+ if (!existsSync(local)) continue;
275
+ if ((await hashFile(local)) === baseHash) {
276
+ if (!dryRun) {
277
+ await rm(local);
278
+ await pruneEmptyDirectories(target, path);
279
+ }
280
+ record(path, 'removed');
281
+ } else {
282
+ record(path, 'kept-removed-upstream');
283
+ }
284
+ }
285
+
286
+ const warnings: string[] = [];
287
+ if (dirtyGit && !dryRun) {
288
+ warnings.push('The workspace had uncommitted changes before the update; "git diff" now mixes them with the update.');
289
+ }
290
+ if (!hadBaseline) {
291
+ warnings.push(
292
+ 'This workspace has no recorded baseline, so every file that differs from upstream counts as a local change. Review the conflicts, or commit and rerun with --force.',
293
+ );
294
+ }
295
+
296
+ let installed = false;
297
+ let uvSynced = false;
298
+ if (!dryRun) {
299
+ writeMarkerFile(target, {
300
+ version: 1,
301
+ runtimeVersion: toVersion,
302
+ created: marker?.created ?? new Date().toISOString(),
303
+ ...(marker?.project ? { project: marker.project } : {}),
304
+ updated: new Date().toISOString(),
305
+ files: nextFiles,
306
+ });
307
+
308
+ if (options.install !== false && changes.length > 0) {
309
+ options.onProgress?.('Installing Node and Python dependencies...');
310
+ try {
311
+ const setup = runSetup(target);
312
+ installed = setup.npm;
313
+ uvSynced = setup.uv;
314
+ if (setup.warning) warnings.push(setup.warning);
315
+ } catch (error) {
316
+ warnings.push(`Dependency installation failed: ${error instanceof Error ? error.message : String(error)}`);
317
+ }
318
+ }
319
+ }
320
+
321
+ return {
322
+ targetDir: target,
323
+ fromVersion,
324
+ toVersion,
325
+ hadBaseline,
326
+ changes,
327
+ keptLocal,
328
+ dryRun,
329
+ installed,
330
+ uvSynced,
331
+ dirtyGit,
332
+ warnings,
333
+ };
334
+ }
335
+
336
+ const LABELS: Record<UpdateAction, string> = {
337
+ added: '+ added',
338
+ updated: '~ updated',
339
+ removed: '- removed',
340
+ overwritten: '! overwritten',
341
+ conflict: '! conflict',
342
+ 'kept-removed-upstream': '= kept (removed upstream, changed locally)',
343
+ 'skipped-deleted-locally': '= skipped (deleted locally)',
344
+ };
345
+
346
+ export function printUpdateSummary(result: UpdateResult): void {
347
+ const from = result.fromVersion ?? 'unknown';
348
+ const verb = result.dryRun ? 'Would update' : 'Updated';
349
+ console.log(`✔ ${verb} ${result.targetDir}: coreslopper ${from} → ${result.toVersion}`);
350
+ if (result.changes.length === 0) console.log(' Workflow files are already up to date.');
351
+ for (const change of result.changes) {
352
+ const suffix = change.action === 'conflict' ? ` → ${change.path}${CONFLICT_SUFFIX}` : '';
353
+ console.log(` ${LABELS[change.action]}: ${change.path}${suffix}`);
354
+ }
355
+ if (result.keptLocal > 0) console.log(` ${result.keptLocal} locally changed file(s) kept; upstream did not change them.`);
356
+ for (const warning of result.warnings) console.warn(` ! ${warning}`);
357
+
358
+ const conflicts = result.changes.filter((change) => change.action === 'conflict').length;
359
+ if (conflicts > 0) {
360
+ console.log('');
361
+ console.log(
362
+ `${conflicts} file(s) changed both locally and upstream. Merge each "${CONFLICT_SUFFIX}" file into the original, then delete it.`,
363
+ );
364
+ }
365
+ if (result.dryRun) {
366
+ console.log('');
367
+ console.log('Dry run: nothing was written.');
368
+ } else if (result.changes.length > 0 && !result.installed) {
369
+ console.log('');
370
+ console.log('Next: npm install && npm run python:sync');
371
+ }
372
+ }
@@ -10,6 +10,10 @@ export interface WorkspaceMarker {
10
10
  runtimeVersion: string;
11
11
  created: string;
12
12
  project?: string;
13
+ /** Last `coreslopper update`. */
14
+ updated?: string;
15
+ /** SHA-256 of every workflow file as installed; the baseline for `coreslopper update`. */
16
+ files?: Record<string, string>;
13
17
  }
14
18
 
15
19
  function readPackage(path: string): { name?: string; version?: string } | undefined {
@@ -0,0 +1,185 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { existsSync } from 'node:fs';
3
+ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { initWorkspace } from '../packages/cli/src/init.ts';
7
+ import { compareVersions, updateWorkspace, type UpdateAction } from '../packages/cli/src/update.ts';
8
+
9
+ const temporary: string[] = [];
10
+
11
+ async function temporaryDir(prefix: string): Promise<string> {
12
+ const directory = await mkdtemp(join(tmpdir(), prefix));
13
+ temporary.push(directory);
14
+ return directory;
15
+ }
16
+
17
+ async function put(root: string, path: string, content: string): Promise<void> {
18
+ await mkdir(dirname(join(root, path)), { recursive: true });
19
+ await writeFile(join(root, path), content);
20
+ }
21
+
22
+ async function runtimeFixture(version: string, files: Record<string, string>): Promise<string> {
23
+ const runtime = await temporaryDir('coreslopper-runtime-');
24
+ await mkdir(join(runtime, 'config'), { recursive: true });
25
+ await cp(join(process.cwd(), 'config', 'defaults.yaml'), join(runtime, 'config', 'defaults.yaml'));
26
+ await cp(join(process.cwd(), 'templates', 'project'), join(runtime, 'templates', 'project'), {
27
+ recursive: true,
28
+ });
29
+ await put(runtime, 'package.json', `${JSON.stringify({ name: 'coreslopper', version, private: true })}\n`);
30
+ await put(runtime, '.mcp.json', '{"mcpServers":{}}\n');
31
+ for (const [path, content] of Object.entries(files)) await put(runtime, path, content);
32
+ return runtime;
33
+ }
34
+
35
+ async function workspaceFrom(runtime: string): Promise<string> {
36
+ const target = await temporaryDir('coreslopper-workspace-');
37
+ await initWorkspace({ targetDir: target, runtimeDir: runtime, install: false, git: false, suno: true });
38
+ return target;
39
+ }
40
+
41
+ function actions(changes: { path: string; action: UpdateAction }[]): Record<string, UpdateAction> {
42
+ // package.json carries the version, so it changes with every release.
43
+ return Object.fromEntries(
44
+ changes.filter((change) => change.path !== 'package.json').map((change) => [change.path, change.action]),
45
+ );
46
+ }
47
+
48
+ const read = (root: string, path: string) => readFile(join(root, path), 'utf8');
49
+
50
+ afterEach(async () => {
51
+ await Promise.all(temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
52
+ });
53
+
54
+ describe('workspace update', () => {
55
+ it('merges upstream changes with local edits using the recorded baseline', async () => {
56
+ const v1 = await runtimeFixture('9.9.0', {
57
+ 'skills/untouched.md': 'v1\n',
58
+ 'skills/local-only.md': 'v1\n',
59
+ 'skills/both.md': 'v1\n',
60
+ 'skills/obsolete.md': 'v1\n',
61
+ 'skills/obsolete-edited.md': 'v1\n',
62
+ 'skills/deleted-locally.md': 'v1\n',
63
+ 'projects/demo/state.json': 'v1\n',
64
+ 'package-lock.json': 'lock v1\n',
65
+ });
66
+ const target = await workspaceFrom(v1);
67
+
68
+ await writeFile(join(target, 'skills/local-only.md'), 'mine\n');
69
+ await writeFile(join(target, 'skills/both.md'), 'mine\n');
70
+ await writeFile(join(target, 'skills/obsolete-edited.md'), 'mine\n');
71
+ await rm(join(target, 'skills/deleted-locally.md'));
72
+ await writeFile(join(target, 'projects/demo/state.json'), 'rendered locally\n');
73
+ await writeFile(join(target, 'package-lock.json'), 'lock rewritten by npm\n');
74
+ await writeFile(join(target, '.env'), 'FAL_KEY=secret\n');
75
+ const mcpBefore = await read(target, '.mcp.json');
76
+
77
+ const v2 = await runtimeFixture('9.10.0', {
78
+ 'skills/untouched.md': 'v2\n',
79
+ 'skills/local-only.md': 'v1\n',
80
+ 'skills/both.md': 'v2\n',
81
+ 'skills/deleted-locally.md': 'v2\n',
82
+ 'skills/new.md': 'v2\n',
83
+ 'projects/demo/state.json': 'v2\n',
84
+ 'projects/demo/new-file.md': 'v2\n',
85
+ 'package-lock.json': 'lock v2\n',
86
+ });
87
+ const result = await updateWorkspace({ targetDir: target, runtimeDir: v2, install: false });
88
+
89
+ expect(result.hadBaseline).toBe(true);
90
+ expect(result.toVersion).toBe('9.10.0');
91
+ expect(actions(result.changes)).toEqual({
92
+ 'skills/untouched.md': 'updated',
93
+ 'skills/both.md': 'conflict',
94
+ 'skills/deleted-locally.md': 'skipped-deleted-locally',
95
+ 'skills/new.md': 'added',
96
+ 'projects/demo/new-file.md': 'added',
97
+ 'package-lock.json': 'updated',
98
+ 'skills/obsolete.md': 'removed',
99
+ 'skills/obsolete-edited.md': 'kept-removed-upstream',
100
+ });
101
+ expect(result.keptLocal).toBe(1);
102
+
103
+ expect(await read(target, 'skills/untouched.md')).toBe('v2\n');
104
+ expect(await read(target, 'skills/local-only.md')).toBe('mine\n');
105
+ expect(await read(target, 'skills/both.md')).toBe('mine\n');
106
+ expect(await read(target, 'skills/both.md.coreslopper-new')).toBe('v2\n');
107
+ expect(existsSync(join(target, 'skills/obsolete.md'))).toBe(false);
108
+ expect(await read(target, 'skills/obsolete-edited.md')).toBe('mine\n');
109
+ expect(existsSync(join(target, 'skills/deleted-locally.md'))).toBe(false);
110
+ expect(await read(target, 'projects/demo/state.json')).toBe('rendered locally\n');
111
+ expect(await read(target, 'package-lock.json')).toBe('lock v2\n');
112
+ expect(await read(target, '.env')).toBe('FAL_KEY=secret\n');
113
+ expect(await read(target, '.mcp.json')).toBe(mcpBefore);
114
+
115
+ const marker = JSON.parse(await read(target, 'coreslopper.workspace.json')) as {
116
+ runtimeVersion: string;
117
+ files: Record<string, string>;
118
+ updated?: string;
119
+ };
120
+ expect(marker.runtimeVersion).toBe('9.10.0');
121
+ expect(marker.updated).toBeTruthy();
122
+ expect(marker.files['skills/obsolete.md']).toBeUndefined();
123
+
124
+ // A second update with the same runtime is a no-op: the conflict was recorded as seen.
125
+ const again = await updateWorkspace({ targetDir: target, runtimeDir: v2, install: false });
126
+ expect(again.changes).toEqual([]);
127
+ });
128
+
129
+ it('writes nothing in a dry run', async () => {
130
+ const v1 = await runtimeFixture('9.9.0', { 'skills/a.md': 'v1\n' });
131
+ const target = await workspaceFrom(v1);
132
+ const markerBefore = await read(target, 'coreslopper.workspace.json');
133
+ const v2 = await runtimeFixture('9.10.0', { 'skills/a.md': 'v2\n' });
134
+
135
+ const result = await updateWorkspace({ targetDir: target, runtimeDir: v2, install: false, dryRun: true });
136
+
137
+ expect(actions(result.changes)).toEqual({ 'skills/a.md': 'updated' });
138
+ expect(await read(target, 'skills/a.md')).toBe('v1\n');
139
+ expect(await read(target, 'coreslopper.workspace.json')).toBe(markerBefore);
140
+ });
141
+
142
+ it('treats differences as conflicts without a baseline and overwrites them with --force', async () => {
143
+ const v1 = await runtimeFixture('9.9.0', { 'skills/a.md': 'v1\n' });
144
+ const target = await workspaceFrom(v1);
145
+ const marker = JSON.parse(await read(target, 'coreslopper.workspace.json')) as Record<string, unknown>;
146
+ delete marker.files;
147
+ await writeFile(join(target, 'coreslopper.workspace.json'), JSON.stringify(marker));
148
+ const v2 = await runtimeFixture('9.10.0', { 'skills/a.md': 'v2\n' });
149
+
150
+ const cautious = await updateWorkspace({ targetDir: target, runtimeDir: v2, install: false, dryRun: true });
151
+ expect(cautious.hadBaseline).toBe(false);
152
+ expect(actions(cautious.changes)['skills/a.md']).toBe('conflict');
153
+
154
+ const forced = await updateWorkspace({ targetDir: target, runtimeDir: v2, install: false, force: true });
155
+ expect(actions(forced.changes)['skills/a.md']).toBe('overwritten');
156
+ expect(await read(target, 'skills/a.md')).toBe('v2\n');
157
+ });
158
+
159
+ it('refuses a downgrade without --force', async () => {
160
+ const v2 = await runtimeFixture('9.10.0', {});
161
+ const target = await workspaceFrom(v2);
162
+ const marker = JSON.parse(await read(target, 'coreslopper.workspace.json')) as Record<string, unknown>;
163
+ marker.runtimeVersion = '9.10.0';
164
+ await writeFile(join(target, 'coreslopper.workspace.json'), JSON.stringify(marker));
165
+ const v1 = await runtimeFixture('9.9.0', {});
166
+
167
+ await expect(updateWorkspace({ targetDir: target, runtimeDir: v1, install: false })).rejects.toThrow(
168
+ /newer than 9\.9\.0/,
169
+ );
170
+ });
171
+
172
+ it('refuses a directory that is not a workspace', async () => {
173
+ const runtime = await runtimeFixture('9.9.0', {});
174
+ const target = await temporaryDir('coreslopper-plain-');
175
+ await expect(updateWorkspace({ targetDir: target, runtimeDir: runtime, install: false })).rejects.toThrow(
176
+ /No coreslopper workspace/,
177
+ );
178
+ });
179
+
180
+ it('compares versions numerically', () => {
181
+ expect(compareVersions('0.10.0', '0.9.1')).toBe(1);
182
+ expect(compareVersions('0.1.0', '0.1.0')).toBe(0);
183
+ expect(compareVersions('0.0.0-stage', '0.1.0')).toBe(-1);
184
+ });
185
+ });