feat: add installer migration framework

This commit is contained in:
Tom Boucher
2026-05-10 22:23:28 -04:00
parent 574d1f448c
commit 6d33055756
12 changed files with 1326 additions and 40 deletions

View File

@@ -76,6 +76,9 @@ const {
isMinimalMode,
stageSkillsForMode,
} = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
const {
runInstallerMigrations,
} = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
// Parse args
const args = process.argv.slice(2);
@@ -6173,24 +6176,6 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
}
}
/**
* Clean up orphaned files from previous GSD versions
*/
function cleanupOrphanedFiles(configDir) {
const orphanedFiles = [
'hooks/gsd-notify.sh', // Removed in v1.6.x
'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0
];
for (const relPath of orphanedFiles) {
const fullPath = path.join(configDir, relPath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
}
}
}
/**
* Clean up orphaned hook registrations from settings.json
*/
@@ -7191,6 +7176,32 @@ function generateManifest(dir, baseDir) {
return manifest;
}
function normalizeInstallRelativePath(relPath) {
if (typeof relPath !== 'string' || relPath.trim() === '' || relPath.includes('\0')) {
return null;
}
if (path.isAbsolute(relPath) || path.win32.isAbsolute(relPath)) {
return null;
}
const normalized = relPath.replace(/\\/g, '/');
const segments = normalized.split('/');
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
return null;
}
return segments.join('/');
}
function resolveInstallRelativePath(baseDir, relPath) {
const normalized = normalizeInstallRelativePath(relPath);
if (!normalized) return null;
const root = path.resolve(baseDir);
const fullPath = path.resolve(root, normalized);
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
return null;
}
return { relPath: normalized, fullPath };
}
/**
* Write file manifest after installation for future modification detection
*/
@@ -7333,8 +7344,11 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP
let written = 0;
try {
const topLevels = new Set();
const safeModified = [];
for (const relPath of modified) {
const norm = relPath.replace(/\\/g, '/');
const norm = normalizeInstallRelativePath(relPath);
if (!norm) continue;
safeModified.push(norm);
const slash = norm.indexOf('/');
topLevels.add(slash === -1 ? '' : norm.slice(0, slash));
}
@@ -7344,14 +7358,16 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP
// Root-level files — copy directly from package source. The transform
// pipeline is directory-oriented; root files don't need path-prefix
// substitution (they're not markdown content with embedded paths).
for (const relPath of modified) {
const norm = relPath.replace(/\\/g, '/');
for (const relPath of safeModified) {
const norm = normalizeInstallRelativePath(relPath);
if (!norm) continue;
if (norm.includes('/')) continue;
const src = path.join(packageSrc, relPath);
if (!fs.existsSync(src)) continue;
const stagedFile = path.join(stageRoot, relPath);
const srcRef = resolveInstallRelativePath(packageSrc, norm);
const stagedRef = resolveInstallRelativePath(stageRoot, norm);
if (!srcRef || !stagedRef || !fs.existsSync(srcRef.fullPath)) continue;
const stagedFile = stagedRef.fullPath;
fs.mkdirSync(path.dirname(stagedFile), { recursive: true });
fs.copyFileSync(src, stagedFile);
fs.copyFileSync(srcRef.fullPath, stagedFile);
}
continue;
}
@@ -7361,15 +7377,15 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP
copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal);
}
for (const relPath of modified) {
for (const relPath of safeModified) {
// Only populate pristine for paths we successfully staged. If a path's
// source dir does not exist (obsolete manifest entry), skip silently
// rather than corrupting pristine with stale data.
const stagedPath = path.join(stageRoot, relPath);
if (!fs.existsSync(stagedPath)) continue;
const out = path.join(pristineDir, relPath);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.copyFileSync(stagedPath, out);
const stagedRef = resolveInstallRelativePath(stageRoot, relPath);
const outRef = resolveInstallRelativePath(pristineDir, relPath);
if (!stagedRef || !outRef || !fs.existsSync(stagedRef.fullPath)) continue;
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
fs.copyFileSync(stagedRef.fullPath, outRef.fullPath);
written++;
}
} finally {
@@ -7408,17 +7424,23 @@ function saveLocalPatches(configDir, pristineCtx) {
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
const pristineDir = path.join(configDir, 'gsd-pristine');
const modified = [];
const pristineHashes = {};
for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
const fullPath = path.join(configDir, relPath);
const safeRef = resolveInstallRelativePath(configDir, relPath);
if (!safeRef) continue;
const { relPath: safeRelPath, fullPath } = safeRef;
if (!fs.existsSync(fullPath)) continue;
const currentHash = fileHash(fullPath);
if (currentHash !== originalHash) {
// Back up the user's modified version
const backupPath = path.join(patchesDir, relPath);
const backupRef = resolveInstallRelativePath(patchesDir, safeRelPath);
if (!backupRef) continue;
const backupPath = backupRef.fullPath;
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
fs.copyFileSync(fullPath, backupPath);
modified.push(relPath);
modified.push(safeRelPath);
pristineHashes[safeRelPath] = originalHash;
}
}
@@ -7438,7 +7460,7 @@ function saveLocalPatches(configDir, pristineCtx) {
// Record the original (pristine) hash for each modified file
// This lets the reapply workflow verify reconstructed pristine files
for (const relPath of modified) {
meta.pristine_hashes[relPath] = manifest.files[relPath];
meta.pristine_hashes[relPath] = pristineHashes[relPath];
}
fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified GSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
@@ -7598,8 +7620,8 @@ function install(isGlobal, runtime = 'claude') {
isGlobal,
});
// Clean up orphaned files from previous versions
cleanupOrphanedFiles(targetDir);
// Run manifest-backed cleanup migrations before package materialization.
runInstallerMigrations({ configDir: targetDir });
// #3245 — Codex idempotent rollback. Capture pre-install state of ALL
// directories and files GSD will mutate so that any post-install validation
@@ -10749,6 +10771,7 @@ if (process.env.GSD_TEST_MODE) {
convertClaudeToCliineMarkdown,
convertClaudeAgentToClineAgent,
writeManifest,
saveLocalPatches,
reportLocalPatches,
validateHookFields,
preserveUserArtifacts,

View File

@@ -609,6 +609,11 @@ The installer (`bin/install.js`, ~3,000 lines) handles:
8. **Manifest tracking** — Writes `gsd-file-manifest.json` for clean uninstall
9. **Uninstall mode** — `--uninstall` removes all GSD files, hooks, and settings
Install-time file moves, stale-artifact cleanup, config rewrites, and user-data
preservation are governed by the Installer Migration Module. See
[Installer Migrations](installer-migrations.md) and
[ADR 0008](adr/0008-installer-migration-module.md).
### Platform Handling
- **Windows:** `windowsHide` on child processes, EPERM/EACCES protection on protected directories, path separator normalization

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-05-09",
"generated": "2026-05-11",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -277,6 +277,7 @@
"init-command-router.cjs",
"init.cjs",
"install-profiles.cjs",
"installer-migrations.cjs",
"intel.cjs",
"learnings.cjs",
"milestone.cjs",

View File

@@ -359,7 +359,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (50 shipped)
## CLI Modules (51 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -385,6 +385,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` |
| `init.cjs` | Compound context loading for each workflow type |
| `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs |
| `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers |
| `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` |
| `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` |
| `milestone.cjs` | Milestone archival, requirements marking |

View File

@@ -9,6 +9,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
| Document | Audience | Description |
|----------|----------|-------------|
| [Architecture](ARCHITECTURE.md) | Contributors, advanced users | System architecture, agent model, data flow, and internal design |
| [Installer Migrations](installer-migrations.md) | Contributors | Architecture for safe install-time migrations, cleanup, preservation, dry-run planning, and rollback |
| [Feature Reference](FEATURES.md) | All users | Feature narratives and requirements for released features (see [CHANGELOG](../CHANGELOG.md) for latest additions) |
| [Command Reference](COMMANDS.md) | All users | Stable commands with syntax, flags, options, and examples |
| [Configuration Reference](CONFIGURATION.md) | All users | Full config schema, workflow toggles, model profiles, git branching |
@@ -29,4 +30,4 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- **All commands at a glance:** [Command Reference](COMMANDS.md)
- **Configuring GSD:** [Configuration Reference](CONFIGURATION.md)
- **How the system works internally:** [Architecture](ARCHITECTURE.md)
- **Contributing or extending:** [CLI Tools Reference](CLI-TOOLS.md) + [Agent Reference](AGENTS.md)
- **Contributing or extending:** [CLI Tools Reference](CLI-TOOLS.md) + [Agent Reference](AGENTS.md)

View File

@@ -0,0 +1,32 @@
# Installer Migration Module owns install-time upgrade safety
- **Status:** Accepted
- **Date:** 2026-05-11
We decided to introduce an explicit Installer Migration Module for install-time file moves, removals, config rewrites, and user-data preservation. Installer upgrade behavior must be represented as versioned migration records that produce a dry-run plan before applying changes.
## Decision
- Add an Installer Migration Module as the owner for upgrade migrations.
- Keep the existing installer materialization pipeline, but move cleanup and feature-retirement behavior into migration records over time.
- Track applied migrations in an install-state file next to the existing file manifest.
- Treat the existing file manifest as the managed-file ownership baseline.
- Treat user-owned artifacts as a single shared policy consumed by preservation and manifest writing.
- Require migrations to plan first, then apply through a shared executor that owns backup, rollback, and reporting.
- Default ambiguous or unknown files to preserve; destructive changes need managed-file evidence or explicit user choice.
- Support dry-run output using the same planner used by apply mode.
## Consequences
- Retiring features requires an explicit migration instead of a hidden cleanup block.
- The installer can remove stale GSD-owned artifacts without guessing about user files.
- Locally modified managed files get a consistent backup path before removal or replacement.
- Future rollback work can become runtime-neutral instead of Codex-specific.
- Migration authors must define ownership evidence, conflict behavior, runtime scope, and non-interactive behavior.
- The installer gains another state file, so tests must cover missing, legacy, and checksum-mismatch state.
## Scope
The first implementation should extract manifest/user-owned helpers, add install-state persistence, add migration planning, and port one existing orphan cleanup into the migration runner. It should not rewrite every runtime installer branch in the first pass.
The detailed module contract lives in `docs/installer-migrations.md`.

View File

@@ -15,9 +15,12 @@ Each ADR documents one architectural decision: what was decided, why, and what c
| [0005-sdk-architecture-seam-map.md](0005-sdk-architecture-seam-map.md) | SDK Architecture seam map for query/runtime surfaces | Accepted |
| [0006-planning-path-projection-module.md](0006-planning-path-projection-module.md) | Planning Path Projection Module for SDK query handlers | Accepted |
| [0007-sdk-package-seam-module.md](0007-sdk-package-seam-module.md) | SDK Package Seam Module owns SDK-to-get-shit-done-cc compatibility | Accepted |
| [0008-installer-migration-module.md](0008-installer-migration-module.md) | Installer Migration Module owns install-time upgrade safety | Accepted |
## Seam map
ADR 0005 is the top-level SDK seam index. It references per-seam ADRs and states the narrow-waist principle each seam follows. Use it as the entry point for understanding SDK module ownership.
ADR 0006 documents how SDK query handlers project planning paths (`cwd → effectiveRoot → .planning/<project>/...`). Cross-reference with the Planning Workspace Module (ADR 0004) for workstream pointer policy.
ADR 0008 documents the Installer Migration Module for safe install-time moves, removals, config rewrites, and user-data preservation.

View File

@@ -0,0 +1,376 @@
# Installer Migration Architecture
This document defines the migration layer for GSD installs and upgrades.
It is for contributors who need to retire files, move install surfaces,
rewrite runtime config, or preserve user data while changing how GSD is
installed.
After reading this document, a contributor should be able to add a new
installer migration without guessing which files are safe to remove or how
to protect local user changes.
## Problem
The installer already handles several upgrade behaviors:
- replacing GSD-managed command, skill, agent, hook, and engine files
- backing up locally modified managed files before replacement
- preserving known user-owned artifacts
- cleaning old hook files and hook registrations
- rewriting runtime-specific configuration formats
- rolling back some failed Codex installs
Those behaviors are currently distributed across install branches. That
works for isolated fixes, but it makes feature retirement risky. A future
change can remove a file from the package while leaving stale installed
copies behind, or delete a user-created file because it happens to live
inside a GSD-managed directory.
The migration layer exists to make upgrade behavior explicit, reviewed, and
repeatable.
## Design Goals
1. Protect user data by default.
2. Remove stale GSD-managed files when a feature is retired.
3. Make destructive actions visible before they run.
4. Record what happened so future installs do not re-run the same migration.
5. Give each runtime the same safety model, even when the concrete files differ.
6. Keep migration authoring small enough that contributors use it instead of
adding another one-off cleanup block.
## Non-Goals
- This is not a general package manager.
- This is not a database migration system.
- This does not automatically infer every historical install layout.
- This does not remove arbitrary user files.
- This does not replace the existing install transforms in one step.
## Terms
**Managed file**
A file that GSD installed and recorded in the install manifest. Managed files
can be replaced automatically when unchanged. If changed locally, they must be
backed up or merged.
**User-owned file**
A file created or maintained by a user workflow or by the user directly. These
files must never be removed just because they sit under a GSD directory.
**Unknown file**
A file found under an install root that is not in the manifest and is not
classified as user-owned. Unknown files are preserved unless a migration
explicitly classifies them with evidence.
**Migration**
A versioned change set that can inspect the current install, produce a plan,
and apply that plan after safety checks pass.
**Plan**
A list of proposed filesystem and config actions. A plan is safe to show to a
user. It describes what will happen and why, without mutating disk.
**Journal**
A per-run record of applied actions and rollback data. It exists so failed
installs can restore the pre-run state where possible.
## State Files
The migration layer uses the existing file manifest and adds one install-state
record.
### File Manifest
The existing manifest remains the ownership baseline. It records the installed
GSD version, install mode, and hashes for distribution-owned files.
The invariant is strict:
- distribution-owned files are manifest-tracked
- user-owned files are preserved and omitted from manifest hashes
- a path cannot be both
### Install State
The installer writes an install-state file next to the manifest.
Required fields:
```json
{
"schema": 1,
"runtime": "codex",
"scope": "global",
"installed_version": "1.50.0",
"install_mode": "full",
"applied_migrations": [
{
"id": "2026-05-11-codex-hooks-layout",
"package_version": "1.50.0",
"checksum": "sha256:...",
"applied_at": "2026-05-11T00:00:00.000Z"
}
]
}
```
The checksum is calculated from the migration definition. If an applied
migration's checksum changes, the installer must warn and refuse to silently
re-run it. Fix-forward migrations should use a new migration id.
## Migration Record
Each migration exports a plain record plus pure planning logic.
Required fields:
```js
module.exports = {
id: '2026-05-11-runtime-layout-example',
title: 'Move legacy commands into runtime skills',
introducedIn: '1.50.0',
runtimes: ['claude', 'codex', 'gemini'],
scopes: ['global', 'local'],
destructive: true,
plan(ctx) {
return [];
}
};
```
The `plan(ctx)` function receives an install context with runtime, scope,
target directory, previous manifest, install state, package manifest, and
filesystem helpers. It returns actions. It must not mutate disk.
Migrations may use helper predicates such as:
- `isManaged(relPath)`
- `isUserOwned(relPath)`
- `hashMatchesManifest(relPath)`
- `exists(relPath)`
- `readJson(relPath)`
- `readToml(relPath)`
## Action Types
Migrations produce a small set of action types. The executor owns mutation,
backup, rollback, and reporting.
### remove-managed
Remove a path only when it is known to be GSD-managed and unchanged from the
previous manifest, or when the migration provides a purpose-built detector for
an old GSD-owned shape.
Use for retired hooks, old generated agents, deprecated command files, and
stale runtime-specific generated artifacts.
### backup-and-remove
Back up a managed path before removal because the file differs from the
previous manifest. The user gets a clear report and can inspect the backup.
Use when a feature retires a managed file that users may have patched.
### move-managed
Move a managed path to a new managed path. If the source was locally modified,
the action becomes `backup-and-move` or a conflict.
Use for layout migrations such as command directories moving into skills.
### rewrite-config
Rewrite a structured config file through a parser or existing structural helper.
String replacement is only acceptable for narrowly-scoped marker blocks with
tests for line-ending and ordering variations.
Use for runtime config, hook registrations, feature flags, and generated
agent registration blocks.
### preserve-user
Declare that a path is user-owned and must survive surrounding directory
replacement. This action is informational in dry-run output and becomes a
copy-through or restore operation during apply.
Use for profile, preferences, hand-authored instructions, and future workflow
outputs.
### prompt-user
Stop non-interactive destructive migration and ask in interactive mode. The
prompt must present concrete choices such as preserve, back up, remove, or
move. The default is preserve.
Use when classification is ambiguous and guessing could lose data.
## Execution Flow
The installer runs migrations before materializing the new package payload.
1. Build install context.
2. Read prior manifest and install state.
3. Build a pre-run snapshot for paths that may be touched.
4. Discover pending migrations by runtime, scope, and applied state.
5. Ask each pending migration for a plan.
6. Merge plans and validate them.
7. Print the plan in dry-run form.
8. Apply safe non-interactive actions.
9. Prompt or stop for ambiguous actions.
10. Write the new package payload.
11. Write the new manifest and install state.
12. Report backups, preserved files, removed stale files, and skipped actions.
If any apply step fails, the executor uses the journal to restore modified
paths where possible. Rollback must never delete files that were not created
or modified by the current installer run.
## Dry Run
The migration runner supports a dry-run mode that prints the plan and exits
without changes.
Dry-run output groups actions by risk:
- will preserve
- will replace unchanged managed files
- will remove stale managed files
- will back up locally modified files
- needs user choice
- blocked
The same planner powers dry-run and apply. There must not be a separate
"preview-only" code path.
## Safety Policy
### Ownership
Never remove an unknown file. Unknown files are preserved unless a migration
contains a specific detector proving the file is a stale GSD artifact.
### Modification Detection
When a path is in the previous manifest:
- hash match means unchanged managed file
- hash mismatch means locally modified managed file
- missing means already removed by the user and should stay removed unless a
migration explicitly needs to recreate it
### User-Owned Artifacts
User-owned artifacts are defined once and consumed by both preservation and
manifest-writing code. Adding a user-owned artifact requires a regression test
that proves it is preserved across reinstall and omitted from the manifest.
### Config Files
Runtime config is mixed ownership. GSD may own marker blocks, generated agent
sections, or hook entries, but it does not own the whole file unless the file
was created as a GSD-only file. Config migrations should remove or rewrite
only the owned portion.
### Rollback
Before applying a migration, the executor records enough data to restore:
- file bytes before overwrite
- directory membership before removing generated directories
- config bytes before structured rewrite
- paths created by the current run
- temporary files created by atomic writes
Rollback is best-effort but must be loud when incomplete.
## First-Time Baseline Migration
The first migration should classify an existing install rather than attempt
to fix every historical layout.
It should:
1. read the current manifest if present
2. scan known runtime install surfaces
3. classify files as managed, user-owned, or unknown
4. report stale GSD-looking files that are not in the current manifest
5. offer actions for ambiguous files instead of deleting them
6. write install state after successful classification
This baseline is the escape hatch for old installs that predate full migration
tracking. It gives the user a reviewable redistribution/removal plan without
requiring the installer to infer every past release transition perfectly.
## Authoring Workflow
When a feature removes or moves install artifacts, the PR must include:
1. a migration record
2. tests for dry-run plan output
3. tests for apply behavior
4. tests for locally modified managed files
5. tests for user-owned files near the changed path
6. an update to release notes if the migration affects user-visible install
behavior
The author must answer these questions in the migration file:
- What old artifact or config shape is being retired?
- How do we prove it is GSD-owned?
- What happens if the user modified it?
- What happens if it is missing?
- What runtime and scope does it affect?
- Is the action safe in non-interactive install?
## Test Matrix
Every migration runner change should cover:
- fresh install with no prior state
- reinstall with matching manifest
- upgrade with pending migration
- locally modified managed file
- unknown file under a GSD directory
- user-owned file under a wiped directory
- failed apply with rollback
- global and local install scopes when applicable
- Windows path separators when paths are serialized
- CRLF input when config files are rewritten
## Implementation Sequence
1. Extract install ownership helpers around the manifest and user-owned artifact list.
2. Add install-state read/write helpers.
3. Add migration record discovery and checksum calculation.
4. Add planner-only dry-run support.
5. Add executor with journaled file actions.
6. Port orphaned hook/file cleanup into the first explicit migration.
7. Port one structured config rewrite into the migration runner.
8. Add the baseline classifier for existing installs.
9. Make new install-affecting PRs require migrations when artifacts are moved,
renamed, or retired.
This sequence keeps the first implementation small: the existing installer
continues to materialize files, while the migration runner takes ownership of
cleanup, classification, and reviewable destructive changes.
## Prior Art
The design borrows from established upgrade systems:
- Flyway versioned migrations: ordered, once-only changes tracked by checksum.
- Flyway dry runs: preview planned mutations before applying them.
- Liquibase changesets and preconditions: declarative changes gated by current
system state.
- Debian conffile policy: preserve local configuration and distinguish package
ownership from user ownership.
- npm lifecycle scripts: useful as packaging context, but not sufficient as the
migration mechanism because uninstall and upgrade context are limited.

View File

@@ -0,0 +1,348 @@
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const MANIFEST_NAME = 'gsd-file-manifest.json';
const INSTALL_STATE_NAME = 'gsd-install-state.json';
const DEFAULT_MIGRATIONS_DIR = path.join(__dirname, 'installer-migrations');
function sha256File(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function readJsonIfPresent(filePath, fallback) {
if (!fs.existsSync(filePath)) return fallback;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return fallback;
}
}
function readInstallManifest(configDir) {
const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null);
if (!manifest || typeof manifest !== 'object') {
return { version: null, timestamp: null, mode: null, files: {} };
}
return {
version: manifest.version || null,
timestamp: manifest.timestamp || null,
mode: manifest.mode || null,
files: manifest.files && typeof manifest.files === 'object' ? manifest.files : {},
};
}
function readInstallState(configDir) {
const state = readJsonIfPresent(path.join(configDir, INSTALL_STATE_NAME), null);
if (!state || typeof state !== 'object') {
return { schemaVersion: 1, appliedMigrations: [] };
}
return {
schemaVersion: state.schemaVersion || 1,
appliedMigrations: Array.isArray(state.appliedMigrations) ? state.appliedMigrations : [],
};
}
function writeInstallState(configDir, state) {
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), JSON.stringify(state, null, 2) + '\n', 'utf8');
return state;
}
function normalizeRelPath(relPath) {
if (typeof relPath !== 'string' || relPath.trim() === '') {
throw new Error('migration action relPath must be a non-empty string');
}
const normalized = relPath.replace(/\\/g, '/');
if (normalized.startsWith('/') || normalized.includes('../') || normalized === '..') {
throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
}
return normalized;
}
function classifyArtifact(configDir, relPath, manifest) {
const normalized = normalizeRelPath(relPath);
const originalHash = manifest.files[normalized] || null;
const fullPath = path.join(configDir, normalized);
if (!fs.existsSync(fullPath)) {
return { classification: originalHash ? 'managed-missing' : 'missing', originalHash, currentHash: null };
}
const currentHash = sha256File(fullPath);
if (!originalHash) {
return { classification: 'unknown', originalHash: null, currentHash };
}
if (currentHash === originalHash) {
return { classification: 'managed-pristine', originalHash, currentHash };
}
return { classification: 'managed-modified', originalHash, currentHash };
}
function appliedMigrationIds(state) {
return new Set(
state.appliedMigrations
.filter((entry) => entry && typeof entry.id === 'string')
.map((entry) => entry.id)
);
}
function validateMigrationRecord(record, source) {
if (!record || typeof record !== 'object') {
throw new Error(`migration record must export an object: ${source}`);
}
if (typeof record.id !== 'string' || record.id.trim() === '') {
throw new Error(`migration record must include a non-empty id: ${source}`);
}
if (typeof record.plan !== 'function') {
throw new Error(`migration record must include a plan function: ${source}`);
}
return record;
}
function discoverInstallerMigrations({ migrationsDir }) {
if (!migrationsDir || !fs.existsSync(migrationsDir)) return [];
return fs.readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.cjs'))
.map((entry) => entry.name)
.sort()
.flatMap((fileName) => {
const source = path.join(migrationsDir, fileName);
delete require.cache[require.resolve(source)];
const exported = require(source);
const records = Array.isArray(exported) ? exported : [exported];
return records.map((record) => validateMigrationRecord(record, source));
});
}
function journalTimestamp(now) {
return now().replace(/[:.]/g, '-');
}
function ensureInsideConfig(configDir, relPath) {
const normalized = normalizeRelPath(relPath);
const fullPath = path.resolve(configDir, normalized);
const root = path.resolve(configDir);
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
throw new Error(`migration path escapes configDir: ${relPath}`);
}
return { normalized, fullPath };
}
function planInstallerMigrations({ configDir, migrations, now = () => new Date().toISOString() }) {
if (!configDir) throw new Error('configDir is required');
if (!Array.isArray(migrations)) throw new Error('migrations must be an array');
const manifest = readInstallManifest(configDir);
const state = readInstallState(configDir);
const applied = appliedMigrationIds(state);
const pending = migrations.filter((migration) => migration && !applied.has(migration.id));
const actions = [];
const blocked = [];
const classifications = new Map();
const classify = (relPath) => {
const normalized = normalizeRelPath(relPath);
if (!classifications.has(normalized)) {
classifications.set(normalized, classifyArtifact(configDir, normalized, manifest));
}
return classifications.get(normalized);
};
for (const migration of pending) {
if (typeof migration.id !== 'string' || migration.id.trim() === '') {
throw new Error('migration id must be a non-empty string');
}
if (typeof migration.plan !== 'function') {
throw new Error(`migration ${migration.id} must provide a plan function`);
}
const plannedActions = migration.plan({
configDir,
manifest,
state,
now,
classifyArtifact: classify,
});
if (!Array.isArray(plannedActions)) {
throw new Error(`migration ${migration.id} plan must return an array`);
}
for (const rawAction of plannedActions) {
const relPath = normalizeRelPath(rawAction.relPath);
const classification = classify(relPath);
let protectedType = rawAction.type;
if (rawAction.type === 'remove-managed' && classification.classification === 'managed-modified') {
protectedType = 'backup-and-remove';
}
if (rawAction.type === 'remove-managed' && classification.classification === 'unknown') {
protectedType = 'preserve-user';
}
const action = {
migrationId: migration.id,
type: protectedType,
relPath,
reason: rawAction.reason || migration.description || '',
classification: classification.classification,
originalHash: classification.originalHash,
currentHash: classification.currentHash,
};
if (action.type !== rawAction.type) {
action.requestedType = rawAction.type;
}
if (action.type === 'backup-and-remove') {
action.backupRelPath = path.posix.join('gsd-migration-backups', migration.id, relPath);
}
if (action.classification === 'unknown') blocked.push(action);
actions.push(action);
}
}
return {
generatedAt: now(),
manifest,
state,
pendingMigrationIds: pending.map((migration) => migration.id),
actions,
blocked,
};
}
function uniqueActionMigrationIds(actions) {
return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))];
}
function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().toISOString() }) {
if (!configDir) throw new Error('configDir is required');
if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions is required');
if (Array.isArray(plan.blocked) && plan.blocked.length > 0) {
throw new Error(`migration plan has ${plan.blocked.length} blocked action(s)`);
}
const appliedAt = now();
const journalRelPath = path.posix.join('gsd-migration-journal', `${journalTimestamp(() => appliedAt)}.json`);
const journalPath = path.join(configDir, journalRelPath);
const rollbackRootRelPath = path.posix.join('gsd-migration-journal', `${journalTimestamp(() => appliedAt)}-rollback`);
const rollbackRoot = path.join(configDir, rollbackRootRelPath);
const journal = {
schemaVersion: 1,
appliedAt,
appliedMigrationIds: uniqueActionMigrationIds(plan.actions),
actions: [],
};
const rollback = [];
try {
for (const action of plan.actions) {
if (action.type === 'preserve-user') {
journal.actions.push({ ...action, status: 'preserved' });
continue;
}
if (action.type !== 'remove-managed' && action.type !== 'backup-and-remove') {
throw new Error(`unsupported migration action type: ${action.type}`);
}
const { normalized, fullPath } = ensureInsideConfig(configDir, action.relPath);
if (!fs.existsSync(fullPath)) {
journal.actions.push({ ...action, status: 'missing' });
continue;
}
const rollbackPath = path.join(rollbackRoot, normalized);
fs.mkdirSync(path.dirname(rollbackPath), { recursive: true });
fs.copyFileSync(fullPath, rollbackPath);
rollback.push({ relPath: normalized, rollbackPath });
if (action.type === 'backup-and-remove') {
const backupRelPath = action.backupRelPath || path.posix.join('gsd-migration-backups', action.migrationId, normalized);
const backupPath = path.join(configDir, backupRelPath);
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
fs.copyFileSync(fullPath, backupPath);
journal.actions.push({ ...action, backupRelPath, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' });
} else {
journal.actions.push({ ...action, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' });
}
fs.rmSync(fullPath, { force: true });
}
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n', 'utf8');
const state = readInstallState(configDir);
const applied = appliedMigrationIds(state);
const nextApplied = [...state.appliedMigrations];
for (const id of journal.appliedMigrationIds) {
if (!applied.has(id)) {
nextApplied.push({ id, appliedAt, journal: journalRelPath });
}
}
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: nextApplied,
});
return {
appliedMigrationIds: journal.appliedMigrationIds,
journalRelPath,
};
} catch (error) {
const rollbackFailures = [];
for (const entry of rollback.reverse()) {
const dest = path.join(configDir, entry.relPath);
try {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(entry.rollbackPath, dest);
} catch (rollbackError) {
rollbackFailures.push({
relPath: entry.relPath,
rollbackPath: entry.rollbackPath,
error: rollbackError.message,
});
}
}
if (rollbackFailures.length > 0) {
const rollbackError = new Error(`migration apply failed and rollback incomplete: ${error.message}`);
rollbackError.cause = error;
rollbackError.rollbackFailures = rollbackFailures;
throw rollbackError;
}
throw error;
}
}
function runInstallerMigrations({
configDir,
migrationsDir = DEFAULT_MIGRATIONS_DIR,
migrations = discoverInstallerMigrations({ migrationsDir }),
now = () => new Date().toISOString(),
} = {}) {
const plan = planInstallerMigrations({ configDir, migrations, now });
if (plan.actions.length === 0) {
return {
appliedMigrationIds: [],
journalRelPath: null,
plan,
};
}
if (plan.blocked.length > 0) {
return {
appliedMigrationIds: [],
journalRelPath: null,
plan,
blocked: plan.blocked,
};
}
const result = applyInstallerMigrationPlan({ configDir, plan, now });
return { ...result, plan };
}
module.exports = {
DEFAULT_MIGRATIONS_DIR,
INSTALL_STATE_NAME,
MANIFEST_NAME,
applyInstallerMigrationPlan,
classifyArtifact,
discoverInstallerMigrations,
planInstallerMigrations,
readInstallManifest,
readInstallState,
runInstallerMigrations,
writeInstallState,
};

View File

@@ -0,0 +1,25 @@
'use strict';
const LEGACY_ORPHAN_FILES = [
'hooks/gsd-notify.sh',
'hooks/statusline.js',
];
module.exports = {
id: '2026-05-11-legacy-orphan-files',
description: 'Remove legacy orphan hook files that are still manifest-managed.',
plan: ({ classifyArtifact }) => {
const actions = [];
for (const relPath of LEGACY_ORPHAN_FILES) {
const artifact = classifyArtifact(relPath);
if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') {
actions.push({
type: 'remove-managed',
relPath,
reason: 'legacy orphan hook file retired by installer migration',
});
}
}
return actions;
},
};

View File

@@ -217,3 +217,43 @@ describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => {
);
});
});
describe('manifest path safety', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-manifest-path-safety-'); });
afterEach(() => { cleanup(tmpDir); });
test('saveLocalPatches ignores manifest entries that escape the install root', () => {
const origMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
let mod;
try {
delete require.cache[require.resolve(INSTALL_SCRIPT)];
mod = require(INSTALL_SCRIPT);
} finally {
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = origMode;
}
const outside = path.join(tmpDir, '..', 'outside-managed-file.txt');
fs.writeFileSync(outside, 'outside user data\n', 'utf8');
fs.writeFileSync(
path.join(tmpDir, MANIFEST_NAME),
JSON.stringify({
version: 'legacy',
timestamp: '2026-05-11T00:00:00.000Z',
files: {
'../outside-managed-file.txt': 'deadbeef',
},
}, null, 2),
'utf8'
);
const modified = mod.saveLocalPatches(tmpDir);
assert.deepEqual(modified, []);
assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n');
assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', 'outside-managed-file.txt')), false);
});
});

View File

@@ -0,0 +1,431 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const {
applyInstallerMigrationPlan,
discoverInstallerMigrations,
planInstallerMigrations,
readInstallState,
runInstallerMigrations,
writeInstallState,
} = require('../get-shit-done/bin/lib/installer-migrations.cjs');
function createTempInstall() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installer-migrations-'));
}
function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
function writeFile(root, relPath, content) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
function writeManifest(root, files) {
fs.writeFileSync(
path.join(root, 'gsd-file-manifest.json'),
JSON.stringify({
version: '1.49.0',
timestamp: '2026-05-10T00:00:00.000Z',
mode: 'full',
files,
}, null, 2),
'utf8'
);
}
test('plans a pending migration against an unchanged managed file', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
},
],
},
],
now: () => '2026-05-11T00:00:00.000Z',
});
assert.deepEqual(plan.pendingMigrationIds, ['2026-05-11-remove-old-hook']);
assert.equal(plan.blocked.length, 0);
assert.deepEqual(plan.actions, [
{
migrationId: '2026-05-11-remove-old-hook',
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
classification: 'managed-pristine',
originalHash: sha256('managed hook\n'),
currentHash: sha256('managed hook\n'),
},
]);
} finally {
cleanup(configDir);
}
});
test('plans backup before removal for a modified managed file', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
},
],
},
],
now: () => '2026-05-11T00:00:00.000Z',
});
assert.equal(plan.blocked.length, 0);
assert.equal(plan.actions.length, 1);
assert.equal(plan.actions[0].type, 'backup-and-remove');
assert.equal(plan.actions[0].classification, 'managed-modified');
assert.equal(plan.actions[0].originalHash, sha256('managed hook\n'));
assert.equal(plan.actions[0].currentHash, sha256('user changed hook\n'));
assert.equal(plan.actions[0].backupRelPath, 'gsd-migration-backups/2026-05-11-remove-old-hook/hooks/old-hook.js');
} finally {
cleanup(configDir);
}
});
test('blocks removal of unknown files by preserving them by default', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n');
writeManifest(configDir, {});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/custom-user-hook.js',
reason: 'retired hook',
},
],
},
],
now: () => '2026-05-11T00:00:00.000Z',
});
assert.equal(plan.actions.length, 1);
assert.equal(plan.actions[0].type, 'preserve-user');
assert.equal(plan.actions[0].requestedType, 'remove-managed');
assert.equal(plan.actions[0].classification, 'unknown');
assert.deepEqual(plan.blocked, [plan.actions[0]]);
} finally {
cleanup(configDir);
}
});
test('applies an unblocked plan with a journal and install-state update', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
},
],
},
],
now: () => '2026-05-11T00:00:00.000Z',
});
const result = applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:01.000Z',
});
assert.equal(fs.existsSync(path.join(configDir, 'hooks/old-hook.js')), false);
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
assert.equal(result.journalRelPath, 'gsd-migration-journal/2026-05-11T00-00-01-000Z.json');
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
assert.deepEqual(journal.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
assert.equal(journal.actions[0].relPath, 'hooks/old-hook.js');
const state = readInstallState(configDir);
assert.deepEqual(state.appliedMigrations.map((entry) => entry.id), ['2026-05-11-remove-old-hook']);
} finally {
cleanup(configDir);
}
});
test('rolls back touched files and leaves state unchanged when apply fails', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = {
pendingMigrationIds: ['2026-05-11-remove-old-hook'],
blocked: [],
actions: [
{
migrationId: '2026-05-11-remove-old-hook',
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
classification: 'managed-pristine',
originalHash: sha256('managed hook\n'),
currentHash: sha256('managed hook\n'),
},
{
migrationId: '2026-05-11-remove-old-hook',
type: 'unsupported-test-action',
relPath: 'hooks/other.js',
reason: 'force failure',
classification: 'managed-pristine',
originalHash: null,
currentHash: null,
},
],
};
assert.throws(
() => applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:02.000Z',
}),
/unsupported migration action type/
);
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
assert.deepEqual(readInstallState(configDir).appliedMigrations, []);
assert.equal(fs.existsSync(path.join(configDir, 'gsd-migration-journal', '2026-05-11T00-00-02-000Z.json')), false);
} finally {
cleanup(configDir);
}
});
test('reports rollback restore failures instead of swallowing them', () => {
const configDir = createTempInstall();
const originalCopyFileSync = fs.copyFileSync;
try {
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = {
blocked: [],
actions: [
{
migrationId: '2026-05-11-remove-old-hook',
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
classification: 'managed-pristine',
originalHash: sha256('managed hook\n'),
currentHash: sha256('managed hook\n'),
},
{
migrationId: '2026-05-11-remove-old-hook',
type: 'unsupported-test-action',
relPath: 'hooks/other.js',
reason: 'force failure',
classification: 'managed-pristine',
originalHash: null,
currentHash: null,
},
],
};
fs.copyFileSync = (src, dest) => {
if (String(src).includes('2026-05-11T00-00-04-000Z-rollback')) {
throw new Error('simulated rollback copy failure');
}
return originalCopyFileSync(src, dest);
};
assert.throws(
() => applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:04.000Z',
}),
(error) => {
assert.match(error.message, /rollback incomplete/);
assert.equal(error.rollbackFailures.length, 1);
assert.equal(error.rollbackFailures[0].relPath, 'hooks/old-hook.js');
return true;
}
);
} finally {
fs.copyFileSync = originalCopyFileSync;
cleanup(configDir);
}
});
test('skips migration records already present in install state', () => {
const configDir = createTempInstall();
try {
writeManifest(configDir, {});
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: [
{
id: '2026-05-11-remove-old-hook',
appliedAt: '2026-05-11T00:00:00.000Z',
journal: 'gsd-migration-journal/prior.json',
},
],
});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => {
throw new Error('already-applied migration planner must not run');
},
},
],
now: () => '2026-05-11T00:00:03.000Z',
});
assert.deepEqual(plan.pendingMigrationIds, []);
assert.deepEqual(plan.actions, []);
assert.deepEqual(plan.blocked, []);
} finally {
cleanup(configDir);
}
});
test('discovers migration records from a directory in filename order', () => {
const configDir = createTempInstall();
try {
const migrationsDir = path.join(configDir, 'migrations');
fs.mkdirSync(migrationsDir, { recursive: true });
fs.writeFileSync(
path.join(migrationsDir, '002-second.cjs'),
"module.exports = { id: 'second', description: 'second', plan: () => [] };\n",
'utf8'
);
fs.writeFileSync(
path.join(migrationsDir, '001-first.cjs'),
"module.exports = { id: 'first', description: 'first', plan: () => [] };\n",
'utf8'
);
const migrations = discoverInstallerMigrations({ migrationsDir });
assert.deepEqual(migrations.map((migration) => migration.id), ['first', 'second']);
} finally {
cleanup(configDir);
}
});
test('rejects migration actions that escape the install root', () => {
const configDir = createTempInstall();
try {
writeManifest(configDir, {});
assert.throws(
() => planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-bad-path',
description: 'Bad path',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/../../outside.js',
reason: 'bad path',
},
],
},
],
}),
/relPath must stay inside configDir/
);
} finally {
cleanup(configDir);
}
});
test('runs discovered installer migrations against manifest-managed legacy orphan files', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/statusline.js', 'legacy managed hook\n');
writeFile(configDir, 'hooks/custom.js', 'custom hook\n');
writeManifest(configDir, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
const result = runInstallerMigrations({
configDir,
now: () => '2026-05-11T00:00:05.000Z',
});
assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false);
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom.js'), 'utf8'), 'custom hook\n');
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-legacy-orphan-files']);
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['2026-05-11-legacy-orphan-files']);
} finally {
cleanup(configDir);
}
});