@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,98 @@
1
+ import path from "node:path";
2
+ import { loadTemplate } from "../spec.js";
3
+ import { loadAiwsPackage } from "../aiws-package.js";
4
+ import { resolveWorkspaceRoot } from "../workspace.js";
5
+ import { readText, pathExists } from "../fs.js";
6
+ import { UserError } from "../errors.js";
7
+ import { normalizeNewlines } from "../hash.js";
8
+ import { findManagedBlock } from "../managed-blocks.js";
9
+ import { BackupSession } from "../backup.js";
10
+ import { normalizeRel, joinRel } from "../path-utils.js";
11
+ import { copyTemplateFileToWorkspace, applyManagedBlocksFromTemplate } from "../template.js";
12
+ import { writeWorkspaceManifest } from "../manifest.js";
13
+
14
+ /**
15
+ * @param {{ targetPath: string }} options
16
+ */
17
+ export async function updateCommand(options) {
18
+ const workspaceRoot = await resolveWorkspaceRoot(options.targetPath, { create: false });
19
+ const aiws = await loadAiwsPackage();
20
+
21
+ const manifestPath = path.join(workspaceRoot, ".aiws", "manifest.json");
22
+ if (!(await pathExists(manifestPath))) {
23
+ throw new UserError("Missing .aiws/manifest.json. Run `aiws init` first.");
24
+ }
25
+ const stored = JSON.parse(await readText(manifestPath));
26
+ const templateId = String(stored.template_id || "workspace");
27
+ const tpl = await loadTemplate(templateId);
28
+
29
+ const update = tpl.manifest.update || {};
30
+ const replaceFiles = (update.replace_file || []).map(normalizeRel);
31
+ const managedBlocks = update.managed_blocks && typeof update.managed_blocks === "object" ? update.managed_blocks : {};
32
+
33
+ // Preflight: managed blocks must exist and be intact.
34
+ for (const [fileRelRaw, blockIdsRaw] of Object.entries(managedBlocks)) {
35
+ const fileRel = normalizeRel(fileRelRaw);
36
+ const abs = joinRel(workspaceRoot, fileRel);
37
+ if (!(await pathExists(abs))) {
38
+ throw new UserError("Managed block file missing; run `aiws init` or restore the file.", { details: `Missing: ${fileRel}` });
39
+ }
40
+ const text = normalizeNewlines(await readText(abs));
41
+ const ids = Array.isArray(blockIdsRaw) ? blockIdsRaw.map(String) : [];
42
+ for (const id of ids) {
43
+ if (!findManagedBlock(text, id)) {
44
+ throw new UserError("Managed block markers are missing or broken; refusing to update.", {
45
+ details: `File: ${fileRel}\nBlock: ${id}\nHint: re-run \`aiws init\` or repair markers manually.`,
46
+ });
47
+ }
48
+ }
49
+ }
50
+
51
+ const backup = new BackupSession({ workspaceRoot, operation: "update" });
52
+ // Backup every file we might touch (including missing, for rollback deletions).
53
+ await backup.recordFile(".aiws/manifest.json", { recordMissing: true });
54
+ for (const f of replaceFiles) await backup.recordFile(f, { recordMissing: true });
55
+ for (const f of Object.keys(managedBlocks)) await backup.recordFile(f, { recordMissing: true });
56
+ await backup.finalize({ extra: { template_id: tpl.templateId } });
57
+
58
+ // Replace files (except manifest, generated later).
59
+ for (const rel of replaceFiles) {
60
+ if (!rel || rel === ".aiws/manifest.json") continue;
61
+ await copyTemplateFileToWorkspace({
62
+ templateDir: tpl.templateDir,
63
+ workspaceRoot,
64
+ relPosix: rel,
65
+ chmod: rel.startsWith(".githooks/") ? 0o755 : undefined,
66
+ });
67
+ }
68
+
69
+ // Update managed blocks only (do not insert missing blocks).
70
+ for (const [fileRelRaw, blockIdsRaw] of Object.entries(managedBlocks)) {
71
+ const fileRel = normalizeRel(fileRelRaw);
72
+ const ids = Array.isArray(blockIdsRaw) ? blockIdsRaw.map(String) : [];
73
+ await applyManagedBlocksFromTemplate({
74
+ templateDir: tpl.templateDir,
75
+ workspaceRoot,
76
+ fileRel,
77
+ blockIds: ids,
78
+ insertIfMissing: false,
79
+ });
80
+ }
81
+
82
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
83
+ const installedAt = String(stored.installed_at || now);
84
+ const tools = Array.isArray(stored.tools) ? stored.tools.map(String) : ["claude", "opencode", "codex", "iflow"];
85
+
86
+ await writeWorkspaceManifest({
87
+ workspaceRoot,
88
+ templateId: tpl.templateId,
89
+ specVersion: tpl.specVersion,
90
+ aiwsVersion: aiws.version,
91
+ installedAt,
92
+ updatedAt: now,
93
+ tools,
94
+ templateManifest: tpl.manifest,
95
+ });
96
+
97
+ console.log(`✓ aiws update: ${workspaceRoot}`);
98
+ }
@@ -0,0 +1,155 @@
1
+ import path from "node:path";
2
+ import { loadTemplate } from "../spec.js";
3
+ import { resolveWorkspaceRoot } from "../workspace.js";
4
+ import { ensureDir, pathExists, readText, writeText } from "../fs.js";
5
+ import { UserError } from "../errors.js";
6
+ import { validateDrift } from "../manifest.js";
7
+ import { runCommand } from "../exec.js";
8
+ import { expandManifestEntries } from "../template.js";
9
+ import { loadAiwsPackage } from "../aiws-package.js";
10
+
11
+ /**
12
+ * @param {string | undefined} v
13
+ */
14
+ function envFlag(v) {
15
+ const s = String(v || "")
16
+ .trim()
17
+ .toLowerCase();
18
+ return s === "1" || s === "true" || s === "yes" || s === "y" || s === "on";
19
+ }
20
+
21
+ /**
22
+ * @returns {string} e.g. 20260204-140026Z
23
+ */
24
+ function nowStampUtc() {
25
+ const d = new Date();
26
+ const y = String(d.getUTCFullYear());
27
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
28
+ const day = String(d.getUTCDate()).padStart(2, "0");
29
+ const hh = String(d.getUTCHours()).padStart(2, "0");
30
+ const mm = String(d.getUTCMinutes()).padStart(2, "0");
31
+ const ss = String(d.getUTCSeconds()).padStart(2, "0");
32
+ const ms = String(d.getUTCMilliseconds()).padStart(3, "0");
33
+ return `${y}${m}${day}-${hh}${mm}${ss}${ms}Z`;
34
+ }
35
+
36
+ /**
37
+ * @returns {string} e.g. 2026-01-28T14:00:26Z
38
+ */
39
+ function nowIsoUtc() {
40
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
41
+ }
42
+
43
+ /**
44
+ * @param {any} error
45
+ * @returns {{ name: string, message: string, details?: string } | null}
46
+ */
47
+ function serializeError(error) {
48
+ if (!error) return null;
49
+ if (error instanceof UserError) {
50
+ return { name: error.name, message: error.message, details: error.details };
51
+ }
52
+ if (error instanceof Error) {
53
+ return { name: error.name, message: error.message };
54
+ }
55
+ return { name: "Error", message: String(error) };
56
+ }
57
+
58
+ /**
59
+ * @param {{ targetPath: string, stamp?: boolean }} options
60
+ */
61
+ export async function validateCommand(options) {
62
+ const stampEnabled = options.stamp === true || envFlag(process.env.AIWS_VALIDATE_STAMP);
63
+ const startedAt = nowIsoUtc();
64
+ const startedTs = Math.floor(Date.now() / 1000);
65
+ let workspaceRoot = "";
66
+ let templateId = "";
67
+ let specVersion = "";
68
+ let aiwsVersion = "";
69
+ let status = /** @type {"ok" | "error"} */ ("ok");
70
+ let exitCode = 0;
71
+ let err = null;
72
+ let stampPath = "";
73
+
74
+ try {
75
+ workspaceRoot = await resolveWorkspaceRoot(options.targetPath, { create: false });
76
+ const aiws = await loadAiwsPackage();
77
+ aiwsVersion = aiws.version;
78
+
79
+ const manifestPath = path.join(workspaceRoot, ".aiws", "manifest.json");
80
+ if (!(await pathExists(manifestPath))) {
81
+ throw new UserError("Missing .aiws/manifest.json. Run `aiws init` first.");
82
+ }
83
+ const stored = JSON.parse(await readText(manifestPath));
84
+ templateId = String(stored.template_id || "workspace");
85
+ const tpl = await loadTemplate(templateId);
86
+ specVersion = tpl.specVersion;
87
+
88
+ // Required files/dirs.
89
+ const required = await expandManifestEntries(tpl.templateDir, tpl.manifest.required || []);
90
+ /** @type {string[]} */
91
+ const missing = [];
92
+ for (const rel of required) {
93
+ const abs = path.join(workspaceRoot, ...String(rel).split("/"));
94
+ if (!(await pathExists(abs))) missing.push(rel);
95
+ }
96
+ if (missing.length > 0) {
97
+ throw new UserError("Missing required files.", { details: missing.join("\n") });
98
+ }
99
+
100
+ // Drift detection.
101
+ await validateDrift({ workspaceRoot, storedManifest: stored, templateManifest: tpl.manifest });
102
+
103
+ // python3 gate.
104
+ const py = await runCommand("python3", ["--version"], { cwd: workspaceRoot });
105
+ if (py.code !== 0) {
106
+ throw new UserError("python3 is required for validate.", { details: py.stderr || py.stdout });
107
+ }
108
+
109
+ // Required gate scripts.
110
+ const wsCheck = await runCommand("python3", ["tools/ws_change_check.py", "--strict"], { cwd: workspaceRoot });
111
+ if (wsCheck.code !== 0) {
112
+ throw new UserError("change gate failed.", { details: wsCheck.stderr || wsCheck.stdout });
113
+ }
114
+ const reqCheck = await runCommand("python3", ["tools/requirements_contract.py", "validate"], { cwd: workspaceRoot });
115
+ if (reqCheck.code !== 0) {
116
+ throw new UserError("requirements contract gate failed.", { details: reqCheck.stderr || reqCheck.stdout });
117
+ }
118
+
119
+ console.log(`✓ aiws validate: ${workspaceRoot}`);
120
+ } catch (error) {
121
+ status = "error";
122
+ err = serializeError(error);
123
+ if (error instanceof UserError) {
124
+ exitCode = error.exitCode;
125
+ } else {
126
+ exitCode = 1;
127
+ }
128
+ throw error;
129
+ } finally {
130
+ if (stampEnabled && workspaceRoot) {
131
+ try {
132
+ const stampDir = path.join(workspaceRoot, ".agentdocs", "tmp", "aiws-validate");
133
+ await ensureDir(stampDir);
134
+ stampPath = path.join(stampDir, `${nowStampUtc()}.json`);
135
+ const stamp = {
136
+ timestamp: startedTs,
137
+ ws_root: workspaceRoot,
138
+ template_id: templateId || null,
139
+ aiws_version: aiwsVersion || null,
140
+ spec_version: specVersion || null,
141
+ started_at: startedAt,
142
+ finished_at: nowIsoUtc(),
143
+ status,
144
+ exit_code: status === "ok" ? 0 : exitCode || 1,
145
+ error: err,
146
+ note: "aiws validate stamp; does not include full stdout/stderr; do not put secrets in validation output.",
147
+ };
148
+ await writeText(stampPath, JSON.stringify(stamp, null, 2) + "\n");
149
+ console.log(`stamp: ${path.relative(workspaceRoot, stampPath)}`);
150
+ } catch (e) {
151
+ console.error(`warn: failed to write validate stamp: ${e instanceof Error ? e.message : String(e)}`);
152
+ }
153
+ }
154
+ }
155
+ }
package/src/errors.js ADDED
@@ -0,0 +1,15 @@
1
+ export class UserError extends Error {
2
+ /**
3
+ * @param {string} message
4
+ * @param {{ exitCode?: number, details?: string }=} options
5
+ */
6
+ constructor(message, options) {
7
+ super(message);
8
+ this.name = "UserError";
9
+ /** @type {number} */
10
+ this.exitCode = options?.exitCode ?? 2;
11
+ /** @type {string | undefined} */
12
+ this.details = options?.details;
13
+ }
14
+ }
15
+
package/src/exec.js ADDED
@@ -0,0 +1,34 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * @param {string} command
5
+ * @param {string[]} args
6
+ * @param {{ cwd?: string }=} options
7
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
8
+ */
9
+ export function runCommand(command, args, options) {
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn(command, args, {
12
+ cwd: options?.cwd,
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ });
15
+
16
+ /** @type {Buffer[]} */
17
+ const out = [];
18
+ /** @type {Buffer[]} */
19
+ const err = [];
20
+
21
+ child.stdout.on("data", (b) => out.push(Buffer.from(b)));
22
+ child.stderr.on("data", (b) => err.push(Buffer.from(b)));
23
+
24
+ child.on("error", (e) => reject(e));
25
+ child.on("close", (code) => {
26
+ resolve({
27
+ code: typeof code === "number" ? code : 1,
28
+ stdout: Buffer.concat(out).toString("utf8"),
29
+ stderr: Buffer.concat(err).toString("utf8"),
30
+ });
31
+ });
32
+ });
33
+ }
34
+
package/src/fs.js ADDED
@@ -0,0 +1,91 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * @param {string} p
6
+ */
7
+ export async function pathExists(p) {
8
+ try {
9
+ await fs.access(p);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * @param {string} p
18
+ */
19
+ export async function ensureDir(p) {
20
+ await fs.mkdir(p, { recursive: true });
21
+ }
22
+
23
+ /**
24
+ * @param {string} p
25
+ */
26
+ export async function readText(p) {
27
+ return await fs.readFile(p, "utf8");
28
+ }
29
+
30
+ /**
31
+ * @param {string} p
32
+ * @param {string} content
33
+ */
34
+ export async function writeText(p, content) {
35
+ await ensureDir(path.dirname(p));
36
+ await fs.writeFile(p, content, "utf8");
37
+ }
38
+
39
+ /**
40
+ * @param {string} src
41
+ * @param {string} dest
42
+ * @param {{ chmod?: number }=} options
43
+ */
44
+ export async function copyFile(src, dest, options) {
45
+ await ensureDir(path.dirname(dest));
46
+ await fs.copyFile(src, dest);
47
+ if (typeof options?.chmod === "number") {
48
+ await fs.chmod(dest, options.chmod);
49
+ return;
50
+ }
51
+ try {
52
+ const st = await fs.stat(src);
53
+ await fs.chmod(dest, st.mode & 0o777);
54
+ } catch {
55
+ // ignore chmod errors (e.g. on certain filesystems)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Very small glob support for patterns ending with `/**`.
61
+ *
62
+ * @param {string} rootDir
63
+ * @param {string} prefixDir
64
+ * @returns {Promise<string[]>} paths relative to rootDir
65
+ */
66
+ export async function listFilesRecursive(rootDir, prefixDir) {
67
+ /** @type {string[]} */
68
+ const out = [];
69
+ const start = path.join(rootDir, prefixDir);
70
+ if (!(await pathExists(start))) return out;
71
+
72
+ /** @type {Array<{ abs: string, rel: string }>} */
73
+ const stack = [{ abs: start, rel: prefixDir }];
74
+ while (stack.length > 0) {
75
+ const cur = stack.pop();
76
+ if (!cur) break;
77
+ const entries = await fs.readdir(cur.abs, { withFileTypes: true });
78
+ for (const ent of entries) {
79
+ const abs = path.join(cur.abs, ent.name);
80
+ const rel = path.posix.join(cur.rel.replaceAll(path.sep, "/"), ent.name);
81
+ if (ent.isDirectory()) {
82
+ stack.push({ abs, rel });
83
+ } else if (ent.isFile()) {
84
+ out.push(rel);
85
+ }
86
+ }
87
+ }
88
+ out.sort();
89
+ return out;
90
+ }
91
+
package/src/hash.js ADDED
@@ -0,0 +1,25 @@
1
+ import crypto from "node:crypto";
2
+ import { readText } from "./fs.js";
3
+
4
+ /**
5
+ * @param {string} s
6
+ */
7
+ export function normalizeNewlines(s) {
8
+ return s.replaceAll("\r\n", "\n");
9
+ }
10
+
11
+ /**
12
+ * @param {string} text
13
+ */
14
+ export function sha256Text(text) {
15
+ return crypto.createHash("sha256").update(text, "utf8").digest("hex");
16
+ }
17
+
18
+ /**
19
+ * @param {string} filePath
20
+ */
21
+ export async function sha256FileText(filePath) {
22
+ const content = normalizeNewlines(await readText(filePath));
23
+ return sha256Text(content);
24
+ }
25
+
@@ -0,0 +1,131 @@
1
+ import { normalizeNewlines, sha256Text } from "./hash.js";
2
+ import { UserError } from "./errors.js";
3
+
4
+ /**
5
+ * @param {string} s
6
+ * @returns {string}
7
+ */
8
+ function escapeRegExp(s) {
9
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10
+ }
11
+
12
+ /**
13
+ * @param {string} text
14
+ * @param {string} blockId
15
+ * @returns {{ beginLineIndex: number, endLineIndex: number, innerText: string, beginLine: string, endLine: string } | null}
16
+ */
17
+ export function findManagedBlock(text, blockId) {
18
+ const t = normalizeNewlines(text);
19
+ const lines = t.split("\n");
20
+ const beginRe = new RegExp(`AIWS_MANAGED_BEGIN:${escapeRegExp(blockId)}(?:\\s|$|-->)`);
21
+ const endRe = new RegExp(`AIWS_MANAGED_END:${escapeRegExp(blockId)}(?:\\s|$|-->)`);
22
+
23
+ let beginLineIndex = -1;
24
+ let endLineIndex = -1;
25
+
26
+ for (let i = 0; i < lines.length; i++) {
27
+ if (beginLineIndex === -1 && beginRe.test(lines[i] ?? "")) {
28
+ beginLineIndex = i;
29
+ continue;
30
+ }
31
+ if (beginLineIndex !== -1 && endRe.test(lines[i] ?? "")) {
32
+ endLineIndex = i;
33
+ break;
34
+ }
35
+ }
36
+
37
+ if (beginLineIndex === -1 || endLineIndex === -1 || endLineIndex <= beginLineIndex) return null;
38
+
39
+ const innerLines = lines.slice(beginLineIndex + 1, endLineIndex);
40
+ let innerText = innerLines.join("\n");
41
+ if (innerLines.length > 0) innerText += "\n";
42
+
43
+ return {
44
+ beginLineIndex,
45
+ endLineIndex,
46
+ innerText,
47
+ beginLine: lines[beginLineIndex] ?? "",
48
+ endLine: lines[endLineIndex] ?? "",
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Extract the canonical block chunk (begin+inner+end) from a template file.
54
+ *
55
+ * @param {string} templateText
56
+ * @param {string} blockId
57
+ * @returns {{ blockChunk: string, innerText: string }}
58
+ */
59
+ export function extractTemplateBlock(templateText, blockId) {
60
+ const t = normalizeNewlines(templateText);
61
+ const block = findManagedBlock(t, blockId);
62
+ if (!block) {
63
+ throw new UserError(`Template is missing managed block: ${blockId}`, { exitCode: 1 });
64
+ }
65
+ const blockChunk = `${block.beginLine}\n${block.innerText}${block.endLine}\n`;
66
+ return { blockChunk, innerText: block.innerText };
67
+ }
68
+
69
+ /**
70
+ * @param {string} targetText
71
+ * @param {string} blockId
72
+ * @param {string} newInnerText normalized; may end with "\n"
73
+ * @param {{ insertIfMissing: boolean, templateBlockChunk?: string } } options
74
+ * @returns {{ nextText: string, changed: boolean, inserted: boolean }}
75
+ */
76
+ export function upsertManagedBlock(targetText, blockId, newInnerText, options) {
77
+ const original = normalizeNewlines(targetText);
78
+ const block = findManagedBlock(original, blockId);
79
+ if (!block) {
80
+ if (!options.insertIfMissing) {
81
+ throw new UserError(`Missing managed block: ${blockId}`);
82
+ }
83
+ if (!options.templateBlockChunk) {
84
+ throw new UserError(`Cannot insert managed block without template chunk: ${blockId}`, { exitCode: 1 });
85
+ }
86
+ const nextText = insertBlockAtTop(original, options.templateBlockChunk);
87
+ return { nextText, changed: true, inserted: true };
88
+ }
89
+
90
+ const lines = original.split("\n");
91
+ const begin = lines.slice(0, block.beginLineIndex + 1);
92
+ const end = lines.slice(block.endLineIndex);
93
+
94
+ const innerLines = newInnerText.endsWith("\n") ? newInnerText.slice(0, -1).split("\n") : newInnerText.split("\n");
95
+ const rebuilt = [...begin, ...innerLines, ...end].join("\n");
96
+
97
+ return { nextText: rebuilt, changed: rebuilt !== original, inserted: false };
98
+ }
99
+
100
+ /**
101
+ * Insert block chunk near the top with minimal disruption:
102
+ * - If the file starts with a Markdown heading (`# ...`), insert after the first line.
103
+ * - Otherwise, insert at the very top.
104
+ *
105
+ * @param {string} text normalized
106
+ * @param {string} blockChunk normalized; should end with "\n"
107
+ */
108
+ function insertBlockAtTop(text, blockChunk) {
109
+ const t = normalizeNewlines(text);
110
+ const chunk = normalizeNewlines(blockChunk).endsWith("\n") ? normalizeNewlines(blockChunk) : `${normalizeNewlines(blockChunk)}\n`;
111
+
112
+ const lines = t.split("\n");
113
+ if (lines.length > 0 && (lines[0] ?? "").startsWith("# ")) {
114
+ const after = lines.slice(1).join("\n");
115
+ const head = `${lines[0]}\n`;
116
+ return `${head}\n${chunk}\n${after}`.replace(/\n{3,}/g, "\n\n");
117
+ }
118
+ return `${chunk}\n${t}`.replace(/\n{3,}/g, "\n\n");
119
+ }
120
+
121
+ /**
122
+ * @param {string} text
123
+ * @param {string} blockId
124
+ */
125
+ export function hashManagedBlockInner(text, blockId) {
126
+ const block = findManagedBlock(text, blockId);
127
+ if (!block) {
128
+ throw new UserError(`Missing managed block: ${blockId}`);
129
+ }
130
+ return sha256Text(block.innerText);
131
+ }