@aipper/aiws 0.0.1

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,153 @@
1
+ import path from "node:path";
2
+ import { normalizeNewlines, sha256FileText, sha256Text } from "./hash.js";
3
+ import { joinRel, normalizeRel } from "./path-utils.js";
4
+ import { pathExists, readText, writeText } from "./fs.js";
5
+ import { findManagedBlock } from "./managed-blocks.js";
6
+ import { UserError } from "./errors.js";
7
+
8
+ /**
9
+ * @param {any} manifest
10
+ */
11
+ export function isPlaceholderManifest(manifest) {
12
+ if (!manifest || typeof manifest !== "object") return true;
13
+ if (manifest.installed_at === "1970-01-01T00:00:00Z" || manifest.updated_at === "1970-01-01T00:00:00Z") return true;
14
+
15
+ const managed = Array.isArray(manifest.managed) ? manifest.managed : [];
16
+ for (const m of managed) {
17
+ if (!m || typeof m !== "object") continue;
18
+ if (typeof m.sha256 === "string" && m.sha256.includes("<sha256>")) return true;
19
+ if (m.blocks && typeof m.blocks === "object") {
20
+ for (const v of Object.values(m.blocks)) {
21
+ if (typeof v === "string" && v.includes("<sha256>")) return true;
22
+ }
23
+ }
24
+ }
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * @param {{ workspaceRoot: string, templateId: string, specVersion: string, aiwsVersion: string, installedAt: string, updatedAt: string, tools: string[], templateManifest: any }} options
30
+ */
31
+ export async function writeWorkspaceManifest(options) {
32
+ const managed = await computeManagedState({
33
+ workspaceRoot: options.workspaceRoot,
34
+ templateManifest: options.templateManifest,
35
+ });
36
+
37
+ const obj = {
38
+ manifest_version: 1,
39
+ template_id: options.templateId,
40
+ spec_version: options.specVersion,
41
+ aiws_version: options.aiwsVersion,
42
+ installed_at: options.installedAt,
43
+ updated_at: options.updatedAt,
44
+ tools: options.tools,
45
+ managed,
46
+ };
47
+
48
+ const outPath = path.join(options.workspaceRoot, ".aiws", "manifest.json");
49
+ await writeText(outPath, JSON.stringify(obj, null, 2) + "\n");
50
+ return obj;
51
+ }
52
+
53
+ /**
54
+ * Compute managed hashes for the current workspace.
55
+ *
56
+ * Note: `.aiws/manifest.json` is intentionally excluded from `managed` to avoid self-hashing.
57
+ *
58
+ * @param {{ workspaceRoot: string, templateManifest: any }} options
59
+ */
60
+ export async function computeManagedState(options) {
61
+ const update = options.templateManifest?.update || {};
62
+ const replaceFiles = Array.isArray(update.replace_file) ? update.replace_file : [];
63
+ const managedBlocks = update.managed_blocks && typeof update.managed_blocks === "object" ? update.managed_blocks : {};
64
+
65
+ /** @type {any[]} */
66
+ const out = [];
67
+
68
+ for (const relRaw of replaceFiles) {
69
+ const rel = normalizeRel(relRaw);
70
+ if (!rel || rel === ".aiws/manifest.json") continue;
71
+ const abs = joinRel(options.workspaceRoot, rel);
72
+ if (!(await pathExists(abs))) throw new UserError(`Missing required managed file: ${rel}`);
73
+ out.push({ path: rel, mode: "replace_file", sha256: await sha256FileText(abs) });
74
+ }
75
+
76
+ for (const [fileRelRaw, blockIdsRaw] of Object.entries(managedBlocks)) {
77
+ const fileRel = normalizeRel(fileRelRaw);
78
+ if (!fileRel) continue;
79
+ const abs = joinRel(options.workspaceRoot, fileRel);
80
+ if (!(await pathExists(abs))) throw new UserError(`Missing required managed file: ${fileRel}`);
81
+ const text = normalizeNewlines(await readText(abs));
82
+ /** @type {Record<string, string>} */
83
+ const blocks = {};
84
+ const ids = Array.isArray(blockIdsRaw) ? blockIdsRaw : [];
85
+ for (const idRaw of ids) {
86
+ const id = String(idRaw);
87
+ const found = findManagedBlock(text, id);
88
+ if (!found) throw new UserError(`Missing managed block: ${id}`, { details: `File: ${fileRel}` });
89
+ blocks[id] = sha256Text(found.innerText);
90
+ }
91
+ out.push({ path: fileRel, mode: "managed_blocks", blocks });
92
+ }
93
+
94
+ out.sort((a, b) => String(a.path).localeCompare(String(b.path)));
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * Validate current workspace against the stored `.aiws/manifest.json` and template contract.
100
+ *
101
+ * @param {{ workspaceRoot: string, storedManifest: any, templateManifest: any }} options
102
+ */
103
+ export async function validateDrift(options) {
104
+ const stored = options.storedManifest;
105
+ if (isPlaceholderManifest(stored)) {
106
+ throw new UserError("Workspace manifest is placeholder/uninitialized.", {
107
+ details: "Run `aiws init` to generate a real .aiws/manifest.json.",
108
+ });
109
+ }
110
+
111
+ const expected = await computeManagedState({ workspaceRoot: options.workspaceRoot, templateManifest: options.templateManifest });
112
+ const currentByPath = new Map(expected.map((e) => [e.path, e]));
113
+
114
+ const managed = Array.isArray(stored.managed) ? stored.managed : [];
115
+ const storedByPath = new Map(managed.map((e) => [String(e.path || ""), e]));
116
+
117
+ /** @type {string[]} */
118
+ const problems = [];
119
+
120
+ for (const [p, cur] of currentByPath.entries()) {
121
+ const s = storedByPath.get(p);
122
+ if (!s) {
123
+ problems.push(`missing manifest entry: ${p}`);
124
+ continue;
125
+ }
126
+ if (cur.mode !== s.mode) {
127
+ problems.push(`mode mismatch: ${p} (expected ${cur.mode}, got ${s.mode})`);
128
+ continue;
129
+ }
130
+ if (cur.mode === "replace_file") {
131
+ if (cur.sha256 !== s.sha256) {
132
+ problems.push(`sha256 mismatch: ${p}`);
133
+ }
134
+ continue;
135
+ }
136
+ if (cur.mode === "managed_blocks") {
137
+ const curBlocks = cur.blocks || {};
138
+ const sBlocks = s.blocks || {};
139
+ for (const [bid, curSha] of Object.entries(curBlocks)) {
140
+ if (!sBlocks[bid]) {
141
+ problems.push(`missing block in manifest: ${p} (${bid})`);
142
+ } else if (sBlocks[bid] !== curSha) {
143
+ problems.push(`block sha256 mismatch: ${p} (${bid})`);
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ if (problems.length > 0) {
150
+ throw new UserError("Workspace drift detected.", { details: problems.join("\n") });
151
+ }
152
+ }
153
+
@@ -0,0 +1,20 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Join a POSIX-style relative path (with `/`) onto an absolute root.
5
+ *
6
+ * @param {string} rootDir
7
+ * @param {string} relPosix
8
+ */
9
+ export function joinRel(rootDir, relPosix) {
10
+ return path.join(rootDir, ...String(relPosix).split("/"));
11
+ }
12
+
13
+ /**
14
+ * Normalize a relative path from manifest (always forward slashes).
15
+ *
16
+ * @param {string} rel
17
+ */
18
+ export function normalizeRel(rel) {
19
+ return String(rel).replaceAll("\\", "/").replaceAll(/\/+/g, "/").replace(/^\/+/, "");
20
+ }
package/src/spec.js ADDED
@@ -0,0 +1,64 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { fileURLToPath } from "node:url";
5
+ import { pathExists, readText } from "./fs.js";
6
+ import { UserError } from "./errors.js";
7
+
8
+ /**
9
+ * @returns {Promise<{ rootDir: string, version: string }>}
10
+ */
11
+ export async function loadSpecPackage() {
12
+ const require = createRequire(import.meta.url);
13
+
14
+ /** @type {string | null} */
15
+ let pkgJsonPath = null;
16
+ try {
17
+ pkgJsonPath = require.resolve("@aipper/aiws-spec/package.json");
18
+ } catch {
19
+ pkgJsonPath = null;
20
+ }
21
+
22
+ /** @type {string} */
23
+ let rootDir;
24
+ if (pkgJsonPath) {
25
+ rootDir = path.dirname(pkgJsonPath);
26
+ } else {
27
+ const here = fileURLToPath(import.meta.url);
28
+ rootDir = path.resolve(path.dirname(here), "../../spec");
29
+ const fallbackPkg = path.join(rootDir, "package.json");
30
+ if (!(await pathExists(fallbackPkg))) {
31
+ throw new UserError("Cannot locate @aipper/aiws-spec. Install it or run inside the monorepo.", {
32
+ exitCode: 1,
33
+ });
34
+ }
35
+ }
36
+
37
+ const pkg = JSON.parse(await readText(path.join(rootDir, "package.json")));
38
+ const version = String(pkg.version || "").trim() || "0.0.0";
39
+ return { rootDir, version };
40
+ }
41
+
42
+ /**
43
+ * @param {string} templateId
44
+ */
45
+ export async function loadTemplate(templateId) {
46
+ if (!templateId || templateId.includes("..") || templateId.includes("/") || templateId.includes("\\")) {
47
+ throw new UserError(`Invalid template id: ${templateId}`);
48
+ }
49
+
50
+ const spec = await loadSpecPackage();
51
+ const templateDir = path.join(spec.rootDir, "templates", templateId);
52
+ const manifestPath = path.join(templateDir, "manifest.json");
53
+
54
+ try {
55
+ await fs.access(manifestPath);
56
+ } catch {
57
+ throw new UserError(`Template not found: ${templateId}`, {
58
+ details: `Missing: ${manifestPath}`,
59
+ });
60
+ }
61
+
62
+ const manifest = JSON.parse(await readText(manifestPath));
63
+ return { templateId, templateDir, manifest, specVersion: spec.version };
64
+ }
@@ -0,0 +1,107 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { listFilesRecursive, pathExists, copyFile, readText, writeText, ensureDir } from "./fs.js";
4
+ import { normalizeRel, joinRel } from "./path-utils.js";
5
+ import { normalizeNewlines } from "./hash.js";
6
+ import { extractTemplateBlock, upsertManagedBlock } from "./managed-blocks.js";
7
+ import { UserError } from "./errors.js";
8
+
9
+ /**
10
+ * Expand manifest paths, supporting patterns ending with `/**`.
11
+ *
12
+ * @param {string} templateDir
13
+ * @param {string[]} entries
14
+ */
15
+ export async function expandManifestEntries(templateDir, entries) {
16
+ /** @type {Set<string>} */
17
+ const out = new Set();
18
+
19
+ for (const raw of entries) {
20
+ const e = normalizeRel(raw);
21
+ if (!e) continue;
22
+ if (e.includes("**")) {
23
+ const idx = e.indexOf("**");
24
+ const prefix = e.slice(0, idx).replace(/\/+$/, "");
25
+ if (!prefix) continue;
26
+ const files = await listFilesRecursive(templateDir, prefix);
27
+ for (const f of files) out.add(normalizeRel(f));
28
+ continue;
29
+ }
30
+ out.add(e);
31
+ }
32
+
33
+ return Array.from(out).sort();
34
+ }
35
+
36
+ /**
37
+ * @param {string} templateDir
38
+ * @param {string} relPosix
39
+ */
40
+ export function templatePath(templateDir, relPosix) {
41
+ const rel = normalizeRel(relPosix);
42
+ return path.join(templateDir, ...rel.split("/"));
43
+ }
44
+
45
+ /**
46
+ * Copy a template file to workspace, creating parent directories.
47
+ *
48
+ * @param {{ templateDir: string, workspaceRoot: string, relPosix: string, chmod?: number }} options
49
+ */
50
+ export async function copyTemplateFileToWorkspace(options) {
51
+ const src = templatePath(options.templateDir, options.relPosix);
52
+ const dest = joinRel(options.workspaceRoot, options.relPosix);
53
+ if (!(await pathExists(src))) throw new UserError(`Template file missing: ${options.relPosix}`, { details: `Missing: ${src}` });
54
+ await copyFile(src, dest, typeof options.chmod === "number" ? { chmod: options.chmod } : undefined);
55
+ }
56
+
57
+ /**
58
+ * Upsert one or more managed blocks from template into an existing workspace file.
59
+ *
60
+ * @param {{ templateDir: string, workspaceRoot: string, fileRel: string, blockIds: string[], insertIfMissing: boolean }} options
61
+ * @returns {Promise<{ changed: boolean }>}
62
+ */
63
+ export async function applyManagedBlocksFromTemplate(options) {
64
+ const fileRel = normalizeRel(options.fileRel);
65
+ const dest = joinRel(options.workspaceRoot, fileRel);
66
+ const src = templatePath(options.templateDir, fileRel);
67
+
68
+ if (!(await pathExists(src))) throw new UserError(`Template file missing: ${fileRel}`);
69
+ const templateText = normalizeNewlines(await readText(src));
70
+
71
+ if (!(await pathExists(dest))) {
72
+ await ensureDir(path.dirname(dest));
73
+ await writeText(dest, templateText);
74
+ return { changed: true };
75
+ }
76
+
77
+ const currentText = normalizeNewlines(await readText(dest));
78
+ let nextText = currentText;
79
+ let anyChanged = false;
80
+
81
+ for (const blockId of options.blockIds) {
82
+ const { blockChunk, innerText } = extractTemplateBlock(templateText, blockId);
83
+ const res = upsertManagedBlock(nextText, blockId, innerText, {
84
+ insertIfMissing: options.insertIfMissing,
85
+ templateBlockChunk: blockChunk,
86
+ });
87
+ nextText = res.nextText;
88
+ if (res.changed) anyChanged = true;
89
+ }
90
+
91
+ if (anyChanged) {
92
+ await writeText(dest, nextText);
93
+ }
94
+ return { changed: anyChanged };
95
+ }
96
+
97
+ /**
98
+ * Ensure workspace has a directory.
99
+ *
100
+ * @param {string} workspaceRoot
101
+ * @param {string} relPosix
102
+ */
103
+ export async function ensureWorkspaceDir(workspaceRoot, relPosix) {
104
+ const abs = joinRel(workspaceRoot, relPosix);
105
+ await fs.mkdir(abs, { recursive: true });
106
+ }
107
+
@@ -0,0 +1,23 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { pathExists } from "./fs.js";
4
+ import { UserError } from "./errors.js";
5
+
6
+ /**
7
+ * @param {string} targetPath
8
+ * @param {{ create?: boolean }=} options
9
+ */
10
+ export async function resolveWorkspaceRoot(targetPath, options) {
11
+ const abs = path.resolve(process.cwd(), targetPath || ".");
12
+ if (await pathExists(abs)) {
13
+ const st = await fs.stat(abs);
14
+ if (!st.isDirectory()) throw new UserError(`Not a directory: ${abs}`);
15
+ return abs;
16
+ }
17
+ if (options?.create) {
18
+ await fs.mkdir(abs, { recursive: true });
19
+ return abs;
20
+ }
21
+ throw new UserError(`Path does not exist: ${abs}`);
22
+ }
23
+