@nm-lang/cli 0.4.0 → 0.6.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,122 @@
1
+ import * as nativeFS from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export class NMProjectCreationError extends Error {
5
+ constructor(code, message, filePath) {
6
+ super(`${code}: ${message}${filePath ? ` (${filePath})` : ""}`);
7
+ this.name = "NMProjectCreationError";
8
+ this.code = code;
9
+ this.path = filePath;
10
+ }
11
+ }
12
+ const failure = (code, message, filePath) => new NMProjectCreationError(code, message, filePath);
13
+ const unsafe = /[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/u;
14
+ const portableSegment = value => value && value !== "." && value !== ".." && !/[<>:"|?*\\/]/u.test(value) && !/[. ]$/u.test(value) && !/^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$)$/iu.test(value.split(".")[0].trimEnd());
15
+ const aliasKey = value => value.normalize("NFC").toLowerCase();
16
+ const sameIdentity = (first, second) => Boolean(first && second && first.dev === second.dev && first.ino === second.ino && !second.isSymbolicLink());
17
+ function inspect(fs, filePath) {
18
+ try { return fs.lstatSync(filePath); }
19
+ catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
20
+ }
21
+ function ancestors(directory) {
22
+ const items = [];
23
+ for (let current = directory; ; current = path.dirname(current)) {
24
+ items.unshift(current);
25
+ if (path.dirname(current) === current) return items;
26
+ }
27
+ }
28
+ function checkDirectories(fs, directories, identities = new Map()) {
29
+ for (const directory of directories) {
30
+ const item = inspect(fs, directory);
31
+ if (item?.isSymbolicLink()) throw failure("NM-PROJECT-LINK-001", "Symbolic links and junctions are not project destinations.", directory);
32
+ if (item && !item.isDirectory()) throw failure("NM-PROJECT-CONFLICT-001", "A project directory is occupied by a file.", directory);
33
+ if (identities.has(directory) && !sameIdentity(identities.get(directory), item)) throw failure("NM-PROJECT-CONFLICT-001", "A directory changed during project creation.", directory);
34
+ }
35
+ }
36
+ function checkAlias(fs, directory) {
37
+ const parent = path.dirname(directory);
38
+ if (!inspect(fs, parent)) return;
39
+ const name = path.basename(directory);
40
+ if (fs.readdirSync(parent).some(entry => entry !== name && aliasKey(entry) === aliasKey(name))) throw failure("NM-PROJECT-PATH-001", "Destination aliases an existing path by case or Unicode normalization.", directory);
41
+ }
42
+ function readPlan(plan, directory) {
43
+ if (!plan || plan.format !== "nm-project-plan" || plan.version !== "0.1" || plan.entry !== "src/main.nm" || !Array.isArray(plan.files) || plan.files.length !== 5) throw failure("NM-PROJECT-PLAN-001", "Expected a complete N/M project plan.");
44
+ const expected = new Set(["nm-workspace.json", "src/main.nm", "src/helper.nm", "tests/main.test.nm", "README.md"]);
45
+ const files = plan.files.map(file => {
46
+ if (!file || typeof file.path !== "string" || typeof file.content !== "string" || unsafe.test(file.path) || !file.path.split("/").every(portableSegment) || !expected.delete(file.path)) throw failure("NM-PROJECT-PLAN-001", "Project files must have unique, portable, contained paths.");
47
+ const target = path.resolve(directory, file.path);
48
+ const relative = path.relative(directory, target);
49
+ if (!relative || relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) throw failure("NM-PROJECT-PATH-001", "Project file escapes its destination.", target);
50
+ return { path: target, content: Buffer.from(file.content, "utf8") };
51
+ });
52
+ if (expected.size) throw failure("NM-PROJECT-PLAN-001", "Project plan is incomplete.");
53
+ return files;
54
+ }
55
+
56
+ /**
57
+ * Sync Node host for the pure Core plan. It never overwrites files. A complete
58
+ * preflight precedes mutation; rollback removes only the files/directories this
59
+ * call created, with matching identities and unchanged content, without recursion.
60
+ * fileSystem is an injectable Node-fs-compatible adapter for host failure tests.
61
+ */
62
+ export function createNMProject(destination, plan, { fileSystem: fs = nativeFS } = {}) {
63
+ if (typeof destination !== "string" || !destination || unsafe.test(destination) || /^[\\/]{2}[?.][\\/]/u.test(destination) || destination.replace(/\\/gu, "/").split("/").some(segment => segment === "..") || /^[a-z]:(?![\\/])/iu.test(destination)) throw failure("NM-PROJECT-PATH-001", "Expected a safe project directory.");
64
+ const directory = path.resolve(destination);
65
+ if (!directory.slice(path.parse(directory).root.length).split(path.sep).every(portableSegment)) throw failure("NM-PROJECT-PATH-001", "Project directory has an unsafe or aliased component.", directory);
66
+ const files = readPlan(plan, directory);
67
+ const directories = [...new Set([directory, ...files.map(file => path.dirname(file.path))].flatMap(ancestors))].sort((a, b) => ancestors(a).length - ancestors(b).length || a.localeCompare(b));
68
+ const identities = new Map(), createdDirectories = [], createdFiles = [];
69
+ try {
70
+ // Validate every ancestor, planned file, conflict, and normalization alias
71
+ // before the first mkdir/open, including broken links (lstat, not exists).
72
+ checkDirectories(fs, directories);
73
+ for (const item of directories) {
74
+ checkAlias(fs, item);
75
+ const stat = inspect(fs, item);
76
+ if (stat) identities.set(item, stat);
77
+ }
78
+ if (inspect(fs, directory) && fs.readdirSync(directory).length) throw failure("NM-PROJECT-CONFLICT-001", "Project destination must be empty.", directory);
79
+ for (const file of files) if (inspect(fs, file.path)) throw failure("NM-PROJECT-CONFLICT-001", "A project file already exists.", file.path);
80
+ for (const item of directories) {
81
+ checkDirectories(fs, ancestors(item), identities);
82
+ if (identities.has(item)) continue;
83
+ checkAlias(fs, item);
84
+ fs.mkdirSync(item);
85
+ const stat = fs.lstatSync(item);
86
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw failure("NM-PROJECT-LINK-001", "A created directory changed into a link.", item);
87
+ identities.set(item, stat);
88
+ createdDirectories.push({ path: item, stat });
89
+ }
90
+ for (const file of files) {
91
+ checkDirectories(fs, directories, identities);
92
+ const descriptor = fs.openSync(file.path, "wx");
93
+ try {
94
+ const stat = fs.fstatSync(descriptor);
95
+ const owned = { ...file, stat, completed: false };
96
+ createdFiles.push(owned);
97
+ fs.writeFileSync(descriptor, file.content);
98
+ owned.completed = true;
99
+ } finally { fs.closeSync(descriptor); }
100
+ }
101
+ return { directory, entryPath: path.join(directory, plan.entry), files: files.map(file => file.path) };
102
+ } catch (error) {
103
+ for (const file of createdFiles.reverse()) {
104
+ try {
105
+ checkDirectories(fs, ancestors(path.dirname(file.path)), identities);
106
+ if (!sameIdentity(file.stat, inspect(fs, file.path))) continue;
107
+ const current = fs.readFileSync(file.path);
108
+ const unchanged = file.completed ? current.equals(file.content) : current.length <= file.content.length && current.equals(file.content.subarray(0, current.length));
109
+ if (unchanged) fs.unlinkSync(file.path);
110
+ } catch { /* Preserve anything whose ownership or contents cannot be verified. */ }
111
+ }
112
+ for (const item of createdDirectories.reverse()) {
113
+ try {
114
+ checkDirectories(fs, ancestors(path.dirname(item.path)), identities);
115
+ if (sameIdentity(item.stat, inspect(fs, item.path))) fs.rmdirSync(item.path);
116
+ } catch { /* Nonempty or changed directories belong to the caller. */ }
117
+ }
118
+ if (error instanceof NMProjectCreationError) throw error;
119
+ const code = error.code === "EEXIST" || error.code === "ENOTEMPTY" ? "NM-PROJECT-CONFLICT-001" : "NM-PROJECT-WRITE-001";
120
+ throw failure(code, error instanceof Error ? error.message : "Unable to create project.", directory);
121
+ }
122
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { NM_DOCTOR_REPORT_FORMAT, NM_DOCTOR_REPORT_VERSION, createNMDoctorReport, formatNMDoctorReport, nmDoctorExpectedVersions, getNMWorkspaceModuleName, evaluateNMLanguagePreviewReadiness, NM_CLI_CONTRACT_VERSION, NM_LANGUAGE_PREVIEW_READINESS_FORMAT, NM_LANGUAGE_PREVIEW_READINESS_VERSION, NM_WORKSPACE_MAX_DOCUMENTS, NM_WORKSPACE_MAX_SOURCE_BYTES, nmCliCommandContracts, nmCliCommands, nmCliExitCodeContract, nmLanguagePreviewReadiness, nmLanguageReleaseChangelog, nmVersionManifest, nmWorkspaceDiagnosticCodes, runNMCLI, validateNMWorkspaceManifestJsonText } from "@nm-lang/core/cli";
2
- export type { NMDoctorCheck, NMDoctorComponentId, NMDoctorObservation, NMDoctorReport, NMDoctorRuntimeObservation, NMCapabilityStatus, NMCLICommand, NMCLICommandContract, NMCLIDeterminism, NMCLIExportTarget, NMCLIOptions, NMCLIOutputKind, NMCLIResult, NMLanguagePreviewReadiness, NMLanguageReleaseChange, NMVersionManifest } from "@nm-lang/core/cli";
1
+ export { NMProjectTemplateError, createNMProjectPlan, isNMProjectName, nmProjectTemplates, NM_DOCTOR_REPORT_FORMAT, NM_DOCTOR_REPORT_VERSION, createNMDoctorReport, formatNMDoctorReport, nmDoctorExpectedVersions, getNMWorkspaceModuleName, evaluateNMLanguagePreviewReadiness, NM_CLI_CONTRACT_VERSION, NM_LANGUAGE_PREVIEW_READINESS_FORMAT, NM_LANGUAGE_PREVIEW_READINESS_VERSION, NM_WORKSPACE_MAX_DOCUMENTS, NM_WORKSPACE_MAX_SOURCE_BYTES, nmCliCommandContracts, nmCliCommands, nmCliExitCodeContract, nmLanguagePreviewReadiness, nmLanguageReleaseChangelog, nmVersionManifest, nmWorkspaceDiagnosticCodes, runNMCLI, validateNMWorkspaceManifestJsonText } from "@nm-lang/core/cli";
2
+ export type { NMProjectTemplateId, NMProjectLocale, NMProjectTemplate, NMProjectPlan, NMProjectCreationResult, NMProjectTemplateErrorCode, NMDoctorCheck, NMDoctorComponentId, NMDoctorObservation, NMDoctorReport, NMDoctorRuntimeObservation, NMCapabilityStatus, NMCLICommand, NMCLICommandContract, NMCLIDeterminism, NMCLIExportTarget, NMCLIOptions, NMCLIOutputKind, NMCLIResult, NMLanguagePreviewReadiness, NMLanguageReleaseChange, NMVersionManifest } from "@nm-lang/core/cli";