@volter/sdk 0.5.203 → 0.5.204

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.203",
5
+ "version": "0.5.204",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -487,7 +487,7 @@
487
487
  "@fortawesome/react-fontawesome": "^3.2.0",
488
488
  "@storybook/react": "10.6.0",
489
489
  "@types/node": "^22.0.0",
490
- "@volter/project": "0.5.203",
490
+ "@volter/project": "0.5.204",
491
491
  "mediabunny": "1.56.2",
492
492
  "postcss": "8.5.28",
493
493
  "rrweb": "2.1.6",
@@ -268,8 +268,10 @@ function resolveAreaScope(): Scope {
268
268
  if (!first) {
269
269
  throw new Error("No workspace area is showing, so scope 'area' has nothing to reach.");
270
270
  }
271
- const id = first.dataset['workspaceDocumentId'] ?? '';
272
- return { container: first, extraRoots: rest, name: 'area', id, title: 'workspace areas' };
271
+ // Every area the scope covers is named, so a reader of the answer never takes one area's id
272
+ // for where a match was when several are showing.
273
+ const ids = [...new Set(shown.map((element) => element.dataset['workspaceDocumentId'] ?? ''))];
274
+ return { container: first, extraRoots: rest, name: 'area', id: ids.join(', '), title: ids.length > 1 ? `${ids.length} workspace areas` : 'workspace area' };
273
275
  }
274
276
 
275
277
  /** The application menu bar, by the stamp `ApplicationMenus` writes on its own root. The menus
@@ -50,6 +50,17 @@ export type StartupRecovery =
50
50
  /** As on `use-compatible-editor`. */
51
51
  summary?: string;
52
52
  }
53
+ | {
54
+ /**
55
+ * A project this product can open once it is brought up to date by the newest release's own `upgrade`
56
+ * or by hand (a project made before 0.5.203 keeps its adapter at its root); `guidance` says how.
57
+ */
58
+ kind: 'bring-project-up-to-date';
59
+ title: 'Bring this project up to date';
60
+ guidance: string;
61
+ /** As on `use-compatible-editor`. */
62
+ summary?: string;
63
+ }
53
64
  | {
54
65
  /** The folder is not a project this product can open; `guidance` says how to make one. */
55
66
  kind: 'make-project';
@@ -92,6 +103,7 @@ export function isStartupRecovery(value: unknown): value is StartupRecovery {
92
103
  case 'restart-editor':
93
104
  return verbs === 'edit .' || verbs === 'close | edit .';
94
105
  case 'use-compatible-editor':
106
+ case 'bring-project-up-to-date':
95
107
  case 'make-project':
96
108
  return candidate['verbs'] === undefined;
97
109
  case 'upgrade-project':
@@ -32,13 +32,13 @@
32
32
  */
33
33
  import { spawn } from 'node:child_process';
34
34
  import { existsSync, lstatSync, readFileSync } from 'node:fs';
35
- import { readFile, rename, rm, writeFile } from 'node:fs/promises';
36
- import { dirname, join, resolve } from 'node:path';
35
+ import { mkdir, readdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises';
36
+ import { dirname, join, relative, resolve, sep } from 'node:path';
37
37
  import { MANIFEST_FILENAME } from '@volter/project/manifest/filename';
38
38
  import { hasManifest } from '@volter/project/manifest/locate';
39
39
  import { compareSemver } from './editor-compatibility';
40
40
 
41
- export const UPGRADE_USAGE = "upgrade [version] # move this project's @volter packages and its engine pin to one release (default: latest)";
41
+ export const UPGRADE_USAGE = "upgrade [version] # move this project's @volter packages and its engine pin to one release (default: latest); from a project made before 0.5.203, run the newest release's own: npx <package>@latest upgrade";
42
42
 
43
43
  /** A product name that is no longer published, as a project still spells it. */
44
44
  export interface RetiredProduct {
@@ -57,12 +57,15 @@ export interface UpgradingProduct {
57
57
  readonly dir: string;
58
58
  /** The names this product replaced: a project declaring one is moved onto this product. */
59
59
  readonly replaces?: readonly RetiredProduct[];
60
+ /** This project's `node_modules` is linked by the product after the upgrade (the game editor's runtime image),
61
+ * even where no link stands yet: it is never told to `npm install`, and no lockfile is rewritten. */
62
+ readonly linksNodeModules?: boolean;
60
63
  }
61
64
 
62
65
  /** The ONE line that moves a project onto `product`, from any version — including a project on a
63
66
  * name `product` replaced, whose own installed command does not know `product` exists. */
64
67
  export function upgradeLine(product: Pick<UpgradingProduct, 'packageName'>): string {
65
- return `npx ${product.packageName} upgrade`;
68
+ return `npx ${product.packageName}@latest upgrade`;
66
69
  }
67
70
 
68
71
  /** The retired product a project's `package.json` still declares, or null. */
@@ -88,6 +91,125 @@ export function retiredProjectError(product: Pick<UpgradingProduct, 'packageName
88
91
  /** The files `create` writes that name the product's package or command, project-relative. */
89
92
  const RENAMED_PRODUCT_FILES = ['.mcp.json', '.codex/config.toml', 'AGENTS.md', 'CLAUDE.md'] as const;
90
93
 
94
+ /** The kit packages 0.5.203 renamed (#284), old name first. */
95
+ const RENAMED_KIT_PACKAGES = [
96
+ ['@volter/editor-project', '@volter/project'],
97
+ ['@volter/editor-sdk', '@volter/sdk'],
98
+ ['@volter/editor-live', '@volter/live'],
99
+ ['@volter/editor-model-play', '@volter/play'],
100
+ ] as const;
101
+
102
+ /**
103
+ * What 0.5.203 (#286) moved from where a project kept it to `editor/`: the adapter from the project's root, and the
104
+ * project's contributions and tools from `src/` (`tool-contribution-convention` read `src/contributions` and
105
+ * `src/tools` until then). Project-relative, from and to.
106
+ */
107
+ const MOVED_TO_EDITOR = [
108
+ ['volter.adapter.ts', 'editor/volter.adapter.ts'],
109
+ ['src/contributions', 'editor/contributions'],
110
+ ['src/tools', 'editor/tools'],
111
+ ] as const;
112
+
113
+ /** Every file under `path` (or `path` itself, a file); none when it is gone. */
114
+ async function allFiles(path: string): Promise<string[]> {
115
+ let entries: import('node:fs').Dirent[];
116
+ try { entries = await readdir(path, { withFileTypes: true }); } catch { return existsSync(path) ? [path] : []; }
117
+ const files: string[] = [];
118
+ for (const entry of entries) {
119
+ const child = join(path, entry.name);
120
+ if (entry.isDirectory()) files.push(...await allFiles(child));
121
+ else files.push(child);
122
+ }
123
+ return files;
124
+ }
125
+
126
+ /** Every script file under `dir`, skipping `node_modules` and dot-folders. */
127
+ async function scriptFiles(dir: string): Promise<string[]> {
128
+ const files: string[] = [];
129
+ let entries: import('node:fs').Dirent[];
130
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return files; }
131
+ for (const entry of entries) {
132
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
133
+ const path = join(dir, entry.name);
134
+ if (entry.isDirectory()) files.push(...await scriptFiles(path));
135
+ else if (/\.(?:[cm]?[jt]sx?)$/.test(entry.name)) files.push(path);
136
+ }
137
+ return files;
138
+ }
139
+
140
+ /** A project's own source that may import the kit: its root script files (the adapter, `vite.config.ts`),
141
+ * and everything under `editor/`, `src/` and `scripts/`. */
142
+ async function projectSourceFiles(project: string): Promise<string[]> {
143
+ const files: string[] = [];
144
+ try {
145
+ for (const entry of await readdir(project, { withFileTypes: true })) {
146
+ if (entry.isFile() && /\.(?:[cm]?[jt]sx?)$/.test(entry.name)) files.push(join(project, entry.name));
147
+ }
148
+ } catch { /* an unreadable project root is refused before this */ }
149
+ for (const folder of ['editor', 'src', 'scripts']) files.push(...await scriptFiles(join(project, folder)));
150
+ return files;
151
+ }
152
+
153
+ /**
154
+ * `text` naming what 0.5.203 moved where it now is: `src/contributions` and `src/tools` as path segments, and the
155
+ * root adapter (`volter.adapter.ts` not already under `editor/`). For the instructions and the catalog records a
156
+ * project carries, and the path strings in its own scripts (`check-idioms.ts` names both folders).
157
+ */
158
+ function rewriteMovedPaths(text: string): string {
159
+ return text
160
+ .replace(/(^|[^\w./-])(\.\/)?src\/(contributions|tools)(?=$|[/'"`\s),;:\]]|\.$)/gm, '$1$2editor/$3')
161
+ .replace(/(^|[^\w./-])(\.\/)?volter\.adapter\.ts\b/gm, '$1$2editor/volter.adapter.ts');
162
+ }
163
+
164
+ /** The text files that name a project's places in words or as data: the instructions `create` wrote (any root
165
+ * Markdown, and the agent skills under `.claude/`, `.agents/` and `.github/`), the root TypeScript configs (their
166
+ * `include`, `exclude` and `paths`) and the catalog's records (`.volter/`, its JSON). Never `node_modules`. */
167
+ async function projectTextFiles(project: string): Promise<string[]> {
168
+ const files: string[] = [];
169
+ try {
170
+ for (const entry of await readdir(project, { withFileTypes: true })) {
171
+ if (entry.isFile() && /(?:\.md|^tsconfig(?:\.[\w-]+)?\.json)$/i.test(entry.name)) files.push(join(project, entry.name));
172
+ }
173
+ } catch { /* an unreadable project root is refused before this */ }
174
+ const under = async (dir: string, pattern: RegExp): Promise<void> => {
175
+ for (const path of await allFiles(dir)) if (!path.includes(`${sep}node_modules${sep}`) && pattern.test(path)) files.push(path);
176
+ };
177
+ for (const folder of ['.claude', '.agents', '.github']) await under(join(project, folder), /\.md$/i);
178
+ await under(join(project, '.volter', 'catalog'), /\.json$/i);
179
+ if (existsSync(join(project, '.volter', 'scaffold-baseline.json'))) files.push(join(project, '.volter', 'scaffold-baseline.json'));
180
+ return files;
181
+ }
182
+
183
+ /** `text` with every import of a renamed kit package (the package itself or a subpath) under its current name, and
184
+ * every path that names its installed folder (`node_modules/@volter/editor-project/src/*`, a tsconfig's `paths`). */
185
+ function renameKitSpecifiers(text: string): string {
186
+ let out = text;
187
+ for (const [from, to] of RENAMED_KIT_PACKAGES) {
188
+ out = out.replace(new RegExp(`(['"\`]|node_modules\\/)${from.replace('/', '\\/')}(?=[/'"\`])`, 'g'), `$1${to}`);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * `text`, a module moving from `fromDir` to `toDir` while the files in `moved` (old path to new) move too, with each
195
+ * relative specifier still naming the same file: `from`, `import`, `import()`, `require()`, `new URL()` and
196
+ * `import.meta.glob()`, and a bare `.` or `..`. A specifier whose module and target both stay is left as written.
197
+ */
198
+ function rebaseRelativeSpecifiers(text: string, fromDir: string, toDir: string, moved: (path: string) => string): string {
199
+ return text.replace(
200
+ /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*|\bnew\s+URL\s*\(\s*|\bimport\.meta\.glob\s*\(\s*)(['"`])(\.{1,2}(?:\/[^'"`]*)?)\2/g,
201
+ (whole: string, lead: string, quote: string, spec: string) => {
202
+ const target = resolve(fromDir, spec);
203
+ if (fromDir === toDir && moved(target) === target) return whole;
204
+ let next = relative(toDir, moved(target)).split('\\').join('/');
205
+ if (next === '') next = '.';
206
+ if (!next.startsWith('.')) next = `./${next}`;
207
+ if (spec.endsWith('/') && !next.endsWith('/')) next = `${next}/`;
208
+ return `${lead}${quote}${next}${quote}`;
209
+ },
210
+ );
211
+ }
212
+
91
213
  /** `text` as a whole word — not part of a longer command, package or name it is the start or the
92
214
  * end of (`<command>-x`, a name's plural). A path after it (`<package>/package.json`) or
93
215
  * punctuation (a name's `'s`) still ends the word. */
@@ -121,6 +243,12 @@ function declaredVersion(spec: string | undefined): string | null {
121
243
  return bare !== undefined && EXACT_VERSION.test(bare) ? bare : null;
122
244
  }
123
245
 
246
+ /** The version a kit package is declared at, for telling which packages move together: an exact spec's, or a
247
+ * local tarball's (`file:…/volter-play-0.5.202.tgz`, how a project built from a checkout declares the kit). */
248
+ function kitVersion(spec: string | undefined): string | null {
249
+ return declaredVersion(spec) ?? spec?.match(/^file:.*-(\d+\.\d+\.\d+)\.tgz$/)?.[1] ?? null;
250
+ }
251
+
124
252
  /**
125
253
  * Write every planned file, or none: each to a temp file beside it first, then all renamed into
126
254
  * place. A failure while writing leaves the project as it was; a failure between the renames
@@ -128,7 +256,7 @@ function declaredVersion(spec: string | undefined): string | null {
128
256
  * one after the other, so a failure on the second left a project with new packages and the old
129
257
  * pin — exactly the mismatch this verb exists to end.
130
258
  */
131
- async function writeAll(files: readonly { readonly path: string; readonly content: string; readonly original: string }[]): Promise<void> {
259
+ async function writeAll(files: readonly { readonly path: string; readonly content: string; readonly original: string; readonly created?: boolean }[]): Promise<void> {
132
260
  const temp = (path: string) => `${path}.upgrade-${process.pid}.tmp`;
133
261
  try {
134
262
  for (const file of files) await writeFile(temp(file.path), file.content);
@@ -140,7 +268,8 @@ async function writeAll(files: readonly { readonly path: string; readonly conten
140
268
  try {
141
269
  for (const file of files) { await rename(temp(file.path), file.path); moved.push(file); }
142
270
  } catch (error) {
143
- for (const file of moved) await writeFile(file.path, file.original).catch(() => {});
271
+ // A file this upgrade made is removed again; any other gets its old content back.
272
+ for (const file of moved) await (file.created ? rm(file.path, { force: true }) : writeFile(file.path, file.original)).catch(() => {});
144
273
  await Promise.all(files.map(file => rm(temp(file.path), { force: true })));
145
274
  throw new Error(`The upgrade could not be put in place, and what had moved was put back: ${error instanceof Error ? error.message : String(error)}`);
146
275
  }
@@ -184,10 +313,16 @@ function jsonLayout(raw: string): (value: unknown) => string {
184
313
  /** `npm view <spec> version dependencies --json`, parsed. */
185
314
  function npmView(spec: string): Promise<Release> {
186
315
  return new Promise((done, fail) => {
187
- // npm is npm.cmd on Windows, and node refuses to spawn a .cmd without a shell (EINVAL).
188
- const child = spawn('npm', ['view', spec, 'version', 'dependencies', '--json'], {
189
- windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32',
190
- });
316
+ // npm is npm.cmd on Windows, and node refuses to spawn a .cmd without a shell (EINVAL). The shell gets one
317
+ // command line (arguments beside `shell` print Node's DEP0190 warning), so the spec, which carries the version
318
+ // the person typed, is a package name and a version or tag, nothing a shell reads.
319
+ if (!/^@?[\w.-]+(?:\/[\w.-]+)?@[\w.+-]+$/.test(spec)) {
320
+ fail(new Error(`${spec} is not a package and a version or tag.`));
321
+ return;
322
+ }
323
+ const child = process.platform === 'win32'
324
+ ? spawn(`npm view ${spec} version dependencies --json`, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], shell: true })
325
+ : spawn('npm', ['view', spec, 'version', 'dependencies', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
191
326
  let out = '';
192
327
  let err = '';
193
328
  child.stdout.on('data', (chunk: Buffer) => { out += chunk.toString('utf8'); });
@@ -249,7 +384,8 @@ export async function upgradeProject(product: UpgradingProduct, requested?: stri
249
384
  // the new kit version. Any other @volter package is the author's choice and is named, not moved.
250
385
  const packageRaw = await readFile(packagePath, 'utf8');
251
386
  const pkg = JSON.parse(packageRaw) as PackageJson;
252
- const previousKit = pkg.devDependencies?.['@volter/project'] ?? pkg.dependencies?.['@volter/project'];
387
+ const previousKit = pkg.devDependencies?.['@volter/project'] ?? pkg.dependencies?.['@volter/project']
388
+ ?? pkg.devDependencies?.['@volter/editor-project'] ?? pkg.dependencies?.['@volter/editor-project'];
253
389
  // Read before a retired name is moved below: a project on a retired name declares none of
254
390
  // this product, and its direction is then read by its engine pin.
255
391
  const currentProduct = declaredVersion(pkg.dependencies?.[product.packageName] ?? pkg.devDependencies?.[product.packageName]);
@@ -272,7 +408,7 @@ export async function upgradeProject(product: UpgradingProduct, requested?: stri
272
408
  // 0. A RETIRED NAME becomes this product, where it stood and at the release — a project that
273
409
  // already declares this product as well just loses the old row.
274
410
  const retired = declaredRetiredProduct(product, project);
275
- const textFiles: { path: string; content: string; original: string }[] = [];
411
+ const textFiles: { path: string; content: string; original: string; created?: boolean }[] = [];
276
412
  if (retired !== null) {
277
413
  const declaresProduct = pkg.dependencies?.[product.packageName] !== undefined || pkg.devDependencies?.[product.packageName] !== undefined;
278
414
  for (const section of ['dependencies', 'devDependencies'] as const) {
@@ -316,12 +452,110 @@ export async function upgradeProject(product: UpgradingProduct, requested?: stri
316
452
  changed.push(`${file}: names ${product.packageName} and \`${product.command}\``);
317
453
  }
318
454
  }
455
+ // 0b. THE KIT'S OLD NAMES AND THE EDITOR FOLDER (0.5.203: #284 renamed the runtime packages, #286 moved a
456
+ // project's editor side into `editor/`). A project made before 0.5.203 declares the kit under its old names and
457
+ // keeps its editor side where #286 moved it from (the adapter at its root, contributions and tools in `src/`),
458
+ // and the release names neither; left alone, the upgraded project was refused on its next open, or opened
459
+ // without its tools. The names move where they stand, in package.json and in the project's own source, and what
460
+ // #286 moved moves into `editor/`, each file with its relative imports rebased.
461
+ for (const section of ['dependencies', 'devDependencies'] as const) {
462
+ const declared = pkg[section];
463
+ if (declared === undefined) continue;
464
+ for (const [from, to] of RENAMED_KIT_PACKAGES) {
465
+ const current = declared[from];
466
+ if (current === undefined) continue;
467
+ if (declared[to] !== undefined) {
468
+ delete declared[from];
469
+ changed.push(`package.json ${section}: ${from} removed (${to} is already declared)`);
470
+ } else {
471
+ pkg[section] = renameKey(pkg[section]!, from, to, current);
472
+ changed.push(`package.json ${section}: ${from} -> ${to}`);
473
+ }
474
+ packagesChanged = true;
475
+ }
476
+ }
477
+ const projectPath = (path: string) => relative(project, path).split('\\').join('/');
478
+ // FILE BY FILE, so a second run finishes what a first left: each file of a moved folder (or the adapter) moves
479
+ // unless a file is already at its destination, which is said and left. A destination folder that exists (empty,
480
+ // or holding what a first run moved) is merged into, not taken as a reason to skip the move.
481
+ const moves: { from: string; to: string }[] = [];
482
+ for (const [from, to] of MOVED_TO_EDITOR) {
483
+ const source = join(project, from);
484
+ if (existsSync(source)) moves.push({ from: source, to: join(project, to) });
485
+ }
486
+ const movedPath = (path: string): string => {
487
+ for (const move of moves) {
488
+ if (path === move.from) return move.to;
489
+ if (path.startsWith(move.from + sep)) return move.to + path.slice(move.from.length);
490
+ // The adapter imported without its extension, or as the `.js` a build would emit.
491
+ const bare = move.from.replace(/\.ts$/, '');
492
+ if (bare !== move.from && (path === bare || path === `${bare}.js`)) return move.to.replace(/\.ts$/, '') + path.slice(bare.length);
493
+ }
494
+ return path;
495
+ };
496
+ const collides = (path: string) => movedPath(path) !== path && existsSync(movedPath(path));
497
+ const stranded: string[] = [];
498
+ for (const move of moves) for (const path of await allFiles(move.from)) if (collides(path)) stranded.push(path);
499
+ for (const path of stranded)
500
+ kept.push(`${projectPath(path)} stays where it is and is not read: ${projectPath(movedPath(path))} already exists; keep one of the two by hand`);
501
+ const willMove = (path: string) => movedPath(path) !== path && !stranded.includes(path);
502
+ const leftBehind: string[] = [];
503
+ for (const path of await projectSourceFiles(project)) {
504
+ const original = await readFile(path, 'utf8');
505
+ const destination = willMove(path) ? movedPath(path) : path;
506
+ const content = rewriteMovedPaths(
507
+ rebaseRelativeSpecifiers(renameKitSpecifiers(original), dirname(path), dirname(destination), (p) => (willMove(p) ? movedPath(p) : p)),
508
+ );
509
+ if (destination !== path) {
510
+ textFiles.push({ path: destination, content, original: '', created: true });
511
+ leftBehind.push(path);
512
+ } else if (content !== original) {
513
+ textFiles.push({ path, content, original });
514
+ changed.push(`${projectPath(path)}: names the kit's current packages and the moved files where they now are`);
515
+ // 0.5.203 fails a game build that reaches into editor/ (src/ is the game): a game file that imported one of
516
+ // the moved files now does, and is named so the person moves what it needs out of editor/. A file left behind
517
+ // because its destination exists is already named, and is not read.
518
+ if (path.startsWith(join(project, 'src') + sep) && !stranded.includes(path) && /(['"`])(?:\.{1,2}\/)+[^'"`]*\beditor\//.test(content) && !/(['"`])(?:\.{1,2}\/)+[^'"`]*\beditor\//.test(original))
519
+ kept.push(`${projectPath(path)} now imports from editor/, which a game build refuses from 0.5.203: move what it uses out of editor/ (src/ is the game)`);
520
+ }
521
+ }
522
+ for (const move of moves) {
523
+ if ((await allFiles(move.from)).some((path) => !stranded.includes(path)))
524
+ changed.push(`${projectPath(move.from)} -> ${projectPath(move.to)} (a project's editor side lives in editor/ from 0.5.203)`);
525
+ }
526
+ // THE FILES THAT NAME THE OLD PLACES IN WORDS OR AS DATA: the instructions `create` wrote (AGENTS.md, IDIOMS.md,
527
+ // CLAUDE.md, the agent skills), the TypeScript configs (the tools' program includes `src/tools`; left, it found no
528
+ // files and `typecheck` failed) and the game editor's catalog records (`.volter/`), which name the files it wrote.
529
+ for (const path of await projectTextFiles(project)) {
530
+ const original = await readFile(path, 'utf8');
531
+ const content = rewriteMovedPaths(renameKitSpecifiers(original));
532
+ if (content === original) continue;
533
+ textFiles.push({ path, content, original });
534
+ changed.push(`${projectPath(path)}: names the moved files and the kit's packages where they now are`);
535
+ }
536
+ // A tool is registered in package.json by its path (`volter.tools`); a registration into a moved folder follows it.
537
+ const volter = pkg['volter'];
538
+ const tools = volter !== null && typeof volter === 'object' && !Array.isArray(volter) ? (volter as { tools?: unknown }).tools : undefined;
539
+ if (Array.isArray(tools)) {
540
+ const rewritten = tools.map((registration: unknown) => {
541
+ const entry = typeof registration === 'string' ? registration : (registration as { entry?: unknown } | null)?.entry;
542
+ if (typeof entry !== 'string' || !entry.startsWith('./')) return registration;
543
+ const next = `./${projectPath(movedPath(join(project, entry.slice(2))))}`;
544
+ if (next === entry) return registration;
545
+ changed.push(`package.json volter.tools: ${entry} -> ${next}`);
546
+ packagesChanged = true;
547
+ return typeof registration === 'string' ? next : { ...(registration as object), entry: next };
548
+ });
549
+ (volter as { tools: unknown[] }).tools = rewritten;
550
+ }
551
+
319
552
  for (const section of ['dependencies', 'devDependencies'] as const) {
320
553
  const declared = pkg[section];
321
554
  if (!declared) continue;
322
555
  for (const [name, current] of Object.entries(declared)) {
323
556
  if (!name.startsWith('@volter/')) continue;
324
- const target = name === product.packageName ? release.version : release.dependencies[name] ?? (current === previousKit ? kit : undefined);
557
+ const target = name === product.packageName ? release.version : release.dependencies[name]
558
+ ?? (current === previousKit || (kitVersion(current) !== null && kitVersion(current) === kitVersion(previousKit)) ? kit : undefined);
325
559
  if (target === undefined) {
326
560
  kept.push(`package.json keeps ${name}@${current}: ${product.packageName}@${release.version} does not name it`);
327
561
  continue;
@@ -329,7 +563,9 @@ export async function upgradeProject(product: UpgradingProduct, requested?: stri
329
563
  if (current === target) continue;
330
564
  declared[name] = target;
331
565
  packagesChanged = true;
332
- changed.push(`package.json ${section}: ${name} ${current} -> ${target}`);
566
+ changed.push(current.startsWith('file:')
567
+ ? `package.json ${section}: ${name} was the local tarball ${current}; it is now ${target} from the registry`
568
+ : `package.json ${section}: ${name} ${current} -> ${target}`);
333
569
  }
334
570
  }
335
571
 
@@ -363,19 +599,49 @@ export async function upgradeProject(product: UpgradingProduct, requested?: stri
363
599
 
364
600
  // A checkout's project links the checkout's own install, which already holds every kit
365
601
  // package; `npm install` there would write into the checkout (`add-play`'s same rule).
366
- const linked = (() => { try { return lstatSync(join(project, 'node_modules')).isSymbolicLink(); } catch { return false; } })();
602
+ const linked = product.linksNodeModules === true
603
+ || (() => { try { return lstatSync(join(project, 'node_modules')).isSymbolicLink(); } catch { return false; } })();
367
604
  const locks = packagesChanged && !linked
368
605
  ? (await Promise.all([join(project, 'package-lock.json'), join(project, 'node_modules', '.package-lock.json')]
369
606
  .map(lockWithoutVolter))).filter((lock): lock is NonNullable<typeof lock> => lock !== null)
370
607
  : [];
371
608
  if (locks.length > 0) changed.push('package-lock.json: the old @volter rows dropped, so npm install resolves the new ones');
372
609
 
373
- await writeAll([
374
- ...(packagesChanged ? [{ path: packagePath, content: jsonLayout(packageRaw)(pkg), original: packageRaw }] : []),
375
- ...(pinChanged ? [{ path: manifestPath, content: jsonLayout(manifestRaw)(manifest), original: manifestRaw }] : []),
376
- ...textFiles,
377
- ...locks,
378
- ]);
610
+ // The folders this run makes for the moved files are removed again if the write fails, so nothing of it is left.
611
+ const madeDirs: string[] = [];
612
+ for (const file of textFiles) {
613
+ if (!file.created) continue;
614
+ const chain: string[] = [];
615
+ for (let dir = dirname(file.path); !existsSync(dir) && dir !== dirname(dir); dir = dirname(dir)) chain.unshift(dir);
616
+ for (const dir of chain) { await mkdir(dir); madeDirs.push(dir); }
617
+ }
618
+ try {
619
+ await writeAll([
620
+ ...(packagesChanged ? [{ path: packagePath, content: jsonLayout(packageRaw)(pkg), original: packageRaw }] : []),
621
+ ...(pinChanged ? [{ path: manifestPath, content: jsonLayout(manifestRaw)(manifest), original: manifestRaw }] : []),
622
+ ...textFiles,
623
+ ...locks,
624
+ ]);
625
+ } catch (error) {
626
+ for (const dir of madeDirs.reverse()) await rmdir(dir).catch(() => undefined);
627
+ throw error;
628
+ }
629
+ // The moved scripts' new copies are in place; only then do the old ones go, and a moved folder's other files
630
+ // (data, a capability stamp) follow as they are. The project is upgraded by now, so a file that cannot be moved or
631
+ // removed (open in another program) is said, not thrown.
632
+ const failed: string[] = [];
633
+ const leftover = (path: string, error: unknown) => (failed.push(path),
634
+ kept.push(`${projectPath(path)} could not be removed (${error instanceof Error ? error.message : String(error)}); the editor does not read it, so delete it by hand`));
635
+ for (const path of leftBehind) await rm(path).catch((error: unknown) => leftover(path, error));
636
+ for (const move of moves) {
637
+ for (const path of await allFiles(move.from)) {
638
+ if (leftBehind.includes(path) || stranded.includes(path)) continue;
639
+ const to = movedPath(path);
640
+ await mkdir(dirname(to), { recursive: true });
641
+ await rename(path, to).catch((error: unknown) => leftover(path, error));
642
+ }
643
+ if (!move.from.endsWith('.ts') && ![...failed, ...stranded].some(path => path.startsWith(move.from + sep))) await rm(move.from, { recursive: true, force: true }).catch(() => undefined);
644
+ }
379
645
  for (const line of warnings) console.warn(`! ${line}`);
380
646
 
381
647
  console.log(changed.length > 0