Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nfd-path-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'create-astro': patch
---

Fixes `create astro` silently scaffolding into the wrong directory on Linux when the project path contains non-ASCII characters. The tar extraction dependency normalizes paths to NFD (decomposed Unicode), which on byte-preserving filesystems such as ext4 creates a parallel decomposed-form directory tree while the CLI still reports success. Template files are now relocated back to the original NFC path after extraction.
42 changes: 42 additions & 0 deletions packages/create-astro/src/actions/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,46 @@ export function isThirdPartyTemplate(tmpl: string) {
return tmpl.includes('/');
}

/**
* Workaround for modern-tar normalizing paths to NFD (decomposed Unicode).
* On Linux (ext4 and most filesystems), NFC and NFD are distinct byte sequences,
* so files extracted by modern-tar end up in a separate NFD-encoded directory
* instead of the user's original NFC-encoded directory.
* See: https://github.com/withastro/astro/issues/17381
*/
export async function relocateNFDFiles(cwd: string) {
const resolvedCwd = path.resolve(cwd);
const nfdCwd = resolvedCwd.normalize('NFD');
if (nfdCwd === resolvedCwd) return;
if (!fs.existsSync(nfdCwd)) return;

// Verify NFC and NFD paths point to different directories (same inode = same dir, e.g. on macOS)
try {
const nfcStat = fs.statSync(resolvedCwd);
const nfdStat = fs.statSync(nfdCwd);
if (nfcStat.ino === nfdStat.ino) return;
} catch {
return;
}

// Move all entries from NFD directory to NFC directory
const entries = fs.readdirSync(nfdCwd);
for (const entry of entries) {
fs.renameSync(path.join(nfdCwd, entry), path.join(resolvedCwd, entry));
}

// Remove the now-empty NFD directory and any empty NFD parent directories
let dirToRemove = nfdCwd;
while (dirToRemove !== path.dirname(dirToRemove)) {
try {
fs.rmdirSync(dirToRemove);
dirToRemove = path.dirname(dirToRemove);
} catch {
break;
}
}
}

async function copyTemplate(tmpl: string, ctx: Context) {
const templateTarget = getTemplateTarget(tmpl, ctx.ref);
// Copy
Expand All @@ -171,6 +211,8 @@ async function copyTemplate(tmpl: string, ctx: Context) {
dir: '.',
});

await relocateNFDFiles(ctx.cwd);

// Process the README file to remove marked sections and update package manager
const readmePath = path.resolve(ctx.cwd, 'README.md');
if (fs.existsSync(readmePath)) {
Expand Down
1 change: 1 addition & 0 deletions packages/create-astro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { verify } from './actions/verify.js';
export {
generateAgentsMd,
processTemplateReadme,
relocateNFDFiles,
removeTemplateMarkerSections,
} from './actions/template.js';
export { setStdout } from './messages.js';
Expand Down
79 changes: 79 additions & 0 deletions packages/create-astro/test/units/relocate-nfd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it } from 'node:test';
import { relocateNFDFiles } from '../../dist/index.js';

// NFC: ü = U+00FC (single code point)
// NFD: ü = u + U+0308 (combining diaeresis)
const NFC_NAME = 'Masa\u00FCst\u00FC';
const NFD_NAME = NFC_NAME.normalize('NFD');

// Skip on macOS where the filesystem normalizes NFC/NFD to the same path
const isNFDTransparent = (() => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nfd-check-'));
const nfcDir = path.join(tmp, NFC_NAME);
const nfdDir = path.join(tmp, NFD_NAME);
fs.mkdirSync(nfcDir);
const same = fs.existsSync(nfdDir) && fs.statSync(nfcDir).ino === fs.statSync(nfdDir).ino;
fs.rmSync(tmp, { recursive: true });
return same;
})();

describe('relocateNFDFiles', { skip: isNFDTransparent && 'filesystem normalizes NFC/NFD' }, () => {
function createTempDir() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'astro-nfd-'));
return tmp;
}

it('moves files from NFD directory to NFC directory', async () => {
const tmp = createTempDir();
const nfcDir = path.join(tmp, NFC_NAME, 'project');
const nfdDir = path.join(tmp, NFD_NAME, 'project');

// Simulate what modern-tar does: create NFC dir (empty) and NFD dir (with files)
fs.mkdirSync(nfcDir, { recursive: true });
fs.mkdirSync(nfdDir, { recursive: true });
fs.writeFileSync(path.join(nfdDir, 'package.json'), '{}');
fs.mkdirSync(path.join(nfdDir, 'src'));
fs.writeFileSync(path.join(nfdDir, 'src', 'index.ts'), 'export {}');

await relocateNFDFiles(nfcDir);

// Files should now be in NFC directory
assert.ok(fs.existsSync(path.join(nfcDir, 'package.json')));
assert.ok(fs.existsSync(path.join(nfcDir, 'src', 'index.ts')));

// NFD directory should be cleaned up
assert.ok(!fs.existsSync(nfdDir));

fs.rmSync(tmp, { recursive: true });
});

it('is a no-op for ASCII-only paths', async () => {
const tmp = createTempDir();
const dir = path.join(tmp, 'my-project');
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, 'package.json'), '{}');

await relocateNFDFiles(dir);

assert.ok(fs.existsSync(path.join(dir, 'package.json')));

fs.rmSync(tmp, { recursive: true });
});

it('is a no-op when NFD directory does not exist', async () => {
const tmp = createTempDir();
const nfcDir = path.join(tmp, NFC_NAME);
fs.mkdirSync(nfcDir);
fs.writeFileSync(path.join(nfcDir, 'package.json'), '{}');

await relocateNFDFiles(nfcDir);

assert.ok(fs.existsSync(path.join(nfcDir, 'package.json')));

fs.rmSync(tmp, { recursive: true });
});
});
Loading