@multileaf/ai-workflow 0.1.0 → 0.1.2

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.
package/dist/files.js CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, lstatSync, realpathSync, } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, lstatSync, realpathSync, rmdirSync, } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  export const nodeFileSystem = {
4
4
  exists: existsSync,
5
5
  read: (path) => readFileSync(path, "utf8"),
@@ -10,14 +10,41 @@ export const nodeFileSystem = {
10
10
  },
11
11
  createExclusive: (path, content) => {
12
12
  mkdirSync(dirname(path), { recursive: true });
13
+ let descriptor;
14
+ let created = false;
13
15
  try {
14
- const descriptor = openSync(path, "wx");
15
- try {
16
- writeFileSync(descriptor, content);
16
+ descriptor = openSync(path, "wx");
17
+ created = true;
18
+ writeFileSync(descriptor, content);
19
+ closeSync(descriptor);
20
+ descriptor = undefined;
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ if (descriptor !== undefined) {
25
+ try {
26
+ closeSync(descriptor);
27
+ }
28
+ catch {
29
+ // Keep the original creation/write error as the primary failure.
30
+ }
17
31
  }
18
- finally {
19
- closeSync(descriptor);
32
+ if (created) {
33
+ try {
34
+ unlinkSync(path);
35
+ }
36
+ catch {
37
+ // Keep the original creation/write error as the primary failure.
38
+ }
20
39
  }
40
+ if (!created && error.code === "EEXIST")
41
+ return false;
42
+ throw error;
43
+ }
44
+ },
45
+ mkdirExclusive: (path) => {
46
+ try {
47
+ mkdirSync(path);
21
48
  return true;
22
49
  }
23
50
  catch (error) {
@@ -37,6 +64,7 @@ export const nodeFileSystem = {
37
64
  .map((entry) => join(entry.parentPath.slice(path.length + 1), entry.name))
38
65
  .sort(),
39
66
  remove: unlinkSync,
67
+ removeDirectory: rmdirSync,
40
68
  pathType: (path) => {
41
69
  try {
42
70
  const entry = lstatSync(path);
@@ -56,3 +84,184 @@ export const nodeFileSystem = {
56
84
  },
57
85
  realpath: realpathSync,
58
86
  };
87
+ /** Apply project containment and symlink checks to every workflow filesystem mutation. */
88
+ export function withSafeProjectWrites(fs, projectRoot) {
89
+ const safeMutation = (path) => assertSafeProjectPath(fs, projectRoot, path);
90
+ return {
91
+ ...fs,
92
+ write: (path, content) => {
93
+ safeMutation(path);
94
+ fs.write(path, content);
95
+ },
96
+ mkdir: (path) => {
97
+ safeMutation(path);
98
+ fs.mkdir(path);
99
+ },
100
+ ...(fs.writeBytes
101
+ ? {
102
+ writeBytes: (path, content) => {
103
+ safeMutation(path);
104
+ fs.writeBytes?.(path, content);
105
+ },
106
+ }
107
+ : {}),
108
+ ...(fs.mkdirExclusive
109
+ ? {
110
+ mkdirExclusive: (path) => {
111
+ safeMutation(path);
112
+ return fs.mkdirExclusive?.(path) ?? false;
113
+ },
114
+ }
115
+ : {}),
116
+ ...(fs.createExclusive
117
+ ? {
118
+ createExclusive: (path, content) => {
119
+ safeMutation(path);
120
+ return fs.createExclusive?.(path, content) ?? false;
121
+ },
122
+ }
123
+ : {}),
124
+ ...(fs.remove
125
+ ? {
126
+ remove: (path) => {
127
+ safeMutation(path);
128
+ fs.remove?.(path);
129
+ },
130
+ }
131
+ : {}),
132
+ ...(fs.removeDirectory
133
+ ? {
134
+ removeDirectory: (path) => {
135
+ safeMutation(path);
136
+ fs.removeDirectory?.(path);
137
+ },
138
+ }
139
+ : {}),
140
+ };
141
+ }
142
+ /** Reject writes that escape the project or cross any existing/dangling symbolic link. */
143
+ export function assertSafeProjectPath(fs, projectRoot, destination) {
144
+ const root = resolve(projectRoot);
145
+ const path = resolve(destination);
146
+ if (!isInside(root, path))
147
+ throw new Error(`Path must stay inside the project: ${destination}`);
148
+ if (!fs.pathType)
149
+ throw new Error("Filesystem cannot safely verify symbolic links for project writes.");
150
+ const rootType = fs.pathType(root);
151
+ if (rootType !== "directory")
152
+ throw new Error(`Project root is not a safe directory: ${root}`);
153
+ const canonicalRoot = fs.realpath?.(root) ?? root;
154
+ let current = path;
155
+ while (isInside(root, current)) {
156
+ const type = fs.pathType(current);
157
+ if (type === "symlink")
158
+ throw new Error(`Project path cannot traverse a symbolic link: ${current}`);
159
+ if (type !== "missing") {
160
+ const canonicalPath = fs.realpath?.(current) ?? current;
161
+ if (!isInside(canonicalRoot, canonicalPath))
162
+ throw new Error(`Project path resolves outside the project: ${current}`);
163
+ }
164
+ if (current === root)
165
+ return;
166
+ current = dirname(current);
167
+ }
168
+ throw new Error(`Path must stay inside the project: ${destination}`);
169
+ }
170
+ /** Create a set of new project files as one rollback-capable transaction. */
171
+ export function writeProjectFilesAtomically(fs, projectRoot, writes, requiredDirectories = []) {
172
+ if (!fs.pathType ||
173
+ !fs.mkdirExclusive ||
174
+ !fs.createExclusive ||
175
+ !fs.remove ||
176
+ !fs.removeDirectory)
177
+ throw new Error("Filesystem does not support safe atomic project installation.");
178
+ const root = resolve(projectRoot);
179
+ const uniquePaths = new Set();
180
+ const directories = new Set();
181
+ for (const directoryPath of requiredDirectories) {
182
+ const directory = resolve(directoryPath);
183
+ assertSafeProjectPath(fs, root, directory);
184
+ if (fs.pathType(directory) !== "directory" && fs.pathType(directory) !== "missing")
185
+ throw new Error(`Installation directory is obstructed: ${directory}`);
186
+ let current = directory;
187
+ while (current !== root) {
188
+ directories.add(current);
189
+ const parent = dirname(current);
190
+ if (parent === current || !isInside(root, parent))
191
+ throw new Error(`Installation directory must stay inside the project: ${current}`);
192
+ current = parent;
193
+ }
194
+ }
195
+ for (const { path } of writes) {
196
+ const destination = resolve(path);
197
+ assertSafeProjectPath(fs, root, destination);
198
+ if (uniquePaths.has(destination))
199
+ throw new Error(`Duplicate installation path: ${destination}`);
200
+ uniquePaths.add(destination);
201
+ let directory = dirname(destination);
202
+ while (directory !== root) {
203
+ assertSafeProjectPath(fs, root, directory);
204
+ directories.add(directory);
205
+ const parent = dirname(directory);
206
+ if (parent === directory || !isInside(root, parent))
207
+ throw new Error(`Installation directory must stay inside the project: ${directory}`);
208
+ directory = parent;
209
+ }
210
+ if (fs.pathType(destination) !== "missing")
211
+ throw new Error(`Installation path appeared or already exists: ${destination}`);
212
+ }
213
+ const orderedDirectories = [...directories].sort((a, b) => relative(root, a).split(sep).length - relative(root, b).split(sep).length);
214
+ const createdDirectories = [];
215
+ const createdFiles = [];
216
+ try {
217
+ for (const directory of orderedDirectories) {
218
+ const type = fs.pathType(directory);
219
+ if (type === "directory")
220
+ continue;
221
+ if (type !== "missing")
222
+ throw new Error(`Installation directory is obstructed: ${directory}`);
223
+ assertSafeProjectPath(fs, root, directory);
224
+ if (!fs.mkdirExclusive(directory))
225
+ throw new Error(`Installation directory appeared during install: ${directory}`);
226
+ createdDirectories.push(directory);
227
+ }
228
+ for (const { path, content } of writes) {
229
+ const destination = resolve(path);
230
+ assertSafeProjectPath(fs, root, destination);
231
+ if (fs.pathType(destination) !== "missing")
232
+ throw new Error(`Installation path appeared during install: ${destination}`);
233
+ if (!fs.createExclusive(destination, content))
234
+ throw new Error(`Installation file appeared during install: ${destination}`);
235
+ createdFiles.push(destination);
236
+ }
237
+ }
238
+ catch (error) {
239
+ const cleanupErrors = [];
240
+ for (const path of createdFiles.reverse()) {
241
+ try {
242
+ if (fs.pathType(path) === "file")
243
+ fs.remove(path);
244
+ }
245
+ catch (cleanupError) {
246
+ cleanupErrors.push(cleanupError);
247
+ }
248
+ }
249
+ for (const path of createdDirectories.reverse()) {
250
+ try {
251
+ if (fs.pathType(path) === "directory")
252
+ fs.removeDirectory(path);
253
+ }
254
+ catch (cleanupError) {
255
+ if (!["ENOTEMPTY", "EEXIST"].includes(cleanupError.code ?? ""))
256
+ cleanupErrors.push(cleanupError);
257
+ }
258
+ }
259
+ if (cleanupErrors.length)
260
+ throw new AggregateError([error, ...cleanupErrors], "Install failed and rollback was incomplete.", { cause: error });
261
+ throw error;
262
+ }
263
+ }
264
+ function isInside(root, destination) {
265
+ const path = relative(root, destination);
266
+ return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
267
+ }
package/dist/generator.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { resolveGeneratedPath } from "./drift.js";
2
2
  export function generateProjectResources(profile, selected, write) {
3
- const linterCommand = profile.quality.linter?.command;
4
- const formatterCommand = profile.quality.formatter?.command;
5
- const testCommand = profile.testing?.command;
3
+ const linterCommand = safeInlineValue(profile.quality.linter?.command);
4
+ const formatterCommand = safeInlineValue(profile.quality.formatter?.command);
5
+ const testCommand = safeInlineValue(profile.testing?.command);
6
6
  if (selected.includes("typescript-quality"))
7
7
  write(resolveGeneratedPath("rules/project-quality.md"), `# Project Quality Rules\n\n- Use ${profile.runtime.languages.map((language) => (language === "typescript" ? "TypeScript" : language)).join(", ")} with strict typing.\n${linterCommand ? `- Run \`${linterCommand}\` before completion.\n` : ""}${formatterCommand ? `- Run \`${formatterCommand}\` before review.\n` : ""}- Keep all generated AI Workflow artifacts in English.\n`);
8
8
  if (selected.includes("tdd-development"))
@@ -14,3 +14,13 @@ export function generateProjectResources(profile, selected, write) {
14
14
  write(resolveGeneratedPath("hooks/quality-check.md"), `# Quality Check Hook\n\nRun the available project quality commands before completion.${linterCommand ? `\n\n- Linter: \`${linterCommand}\`` : ""}${formatterCommand ? `\n- Formatter: \`${formatterCommand}\`` : ""}${testCommand ? `\n- Tests: \`${testCommand}\`` : ""}\n`);
15
15
  }
16
16
  }
17
+ function safeInlineValue(value) {
18
+ return value
19
+ ?.split("")
20
+ .map((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 || character === "`"
21
+ ? " "
22
+ : character)
23
+ .join("")
24
+ .replace(/\s+/g, " ")
25
+ .slice(0, 180);
26
+ }
@@ -16,9 +16,37 @@ function safeFile(file) {
16
16
  }
17
17
  function redactSecrets(content) {
18
18
  return content
19
- .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, "[REDACTED]")
19
+ .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[REDACTED]")
20
20
  .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]")
21
- .replace(/\b(api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|password)\b(\s*[=:]\s*)["']?[^\s,"'}]+/gi, "$1$2[REDACTED]");
21
+ .replace(/(^|[,{;\s])(["']?)([A-Za-z_][A-Za-z0-9_.-]*)(["']?)(\s*[:=]\s*)(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s,;}\]#]+))/gm, (match, boundary, opening, key, closing, operator, doubleQuoted, singleQuoted) => {
22
+ if (!isSensitiveKey(key))
23
+ return match;
24
+ if (doubleQuoted !== undefined)
25
+ return `${boundary}${opening}${key}${closing}${operator}"[REDACTED]"`;
26
+ if (singleQuoted !== undefined)
27
+ return `${boundary}${opening}${key}${closing}${operator}'[REDACTED]'`;
28
+ return `${boundary}${opening}${key}${closing}${operator}[REDACTED]`;
29
+ })
30
+ .replace(/\b(?:github_pat_|gh[pousr]_)[A-Za-z0-9_]{20,}\b/gi, "[REDACTED]")
31
+ .replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gi, "[REDACTED]")
32
+ .replace(/\bAIza[0-9A-Za-z_-]{20,}\b/g, "[REDACTED]")
33
+ .replace(/\bsk-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]")
34
+ .replace(/\bAKIA[A-Z0-9]{16}\b/g, "[REDACTED]");
35
+ }
36
+ function isSensitiveKey(key) {
37
+ const normalized = key.toLowerCase().replace(/[.-]/g, "_");
38
+ const parts = normalized.split("_");
39
+ return (parts.some((part) => [
40
+ "secret",
41
+ "token",
42
+ "password",
43
+ "passwd",
44
+ "credential",
45
+ "credentials",
46
+ "authorization",
47
+ ].includes(part)) ||
48
+ /(^|_)(api|access|private|client)_?key(_|$)/.test(normalized) ||
49
+ /^(aws|azure|gcp|google|cloud|openai|anthropic|stripe|github|gitlab|slack|npm|docker|twilio|sendgrid)(?:_.*)?_(?:key|id)$/.test(normalized));
22
50
  }
23
51
  function isBinary(content) {
24
52
  return [...content].some((character) => {
@@ -81,7 +109,7 @@ function parseFacts(output, allowedFiles) {
81
109
  };
82
110
  });
83
111
  }
84
- export function createProjectInterpreter(root, provider, contextReader = (path) => readFile(path, "utf8")) {
112
+ export function createProjectInterpreter(root, provider, contextReader = async (path) => new TextDecoder("utf-8", { fatal: true }).decode(await readFile(path))) {
85
113
  const projectRoot = resolve(root);
86
114
  return {
87
115
  async interpret(request) {
@@ -98,16 +126,29 @@ export function createProjectInterpreter(root, provider, contextReader = (path)
98
126
  catch {
99
127
  // Injected context readers may provide virtual files in tests or adapters.
100
128
  }
101
- const content = await contextReader(absolute);
129
+ let content;
130
+ try {
131
+ content = await contextReader(absolute);
132
+ }
133
+ catch {
134
+ // Do not send content when decoding or reading it failed.
135
+ continue;
136
+ }
102
137
  if (isBinary(content))
103
138
  continue;
104
- sections.push(`## ${file}\n${redactSecrets(content)}`);
139
+ try {
140
+ sections.push(`## ${file}\n${redactSecrets(content)}`);
141
+ }
142
+ catch {
143
+ // Secret filtering is fail-closed: a failing filter excludes the whole file.
144
+ }
105
145
  }
106
146
  if (sections.length === 0)
107
147
  return [];
148
+ const safeProfile = redactSecrets(JSON.stringify(scoped.profile));
108
149
  const output = await provider.generate({
109
150
  system: "Interpret project conventions from only the supplied context. Return JSON with a facts array. Each fact must have key, string value, confidence from 0 to 1, and evidence entries with source and reason. Do not infer secrets or repeat credentials.",
110
- prompt: `Detected profile:\n${JSON.stringify(scoped.profile)}\n\nScoped project context:\n${sections.join("\n\n")}`,
151
+ prompt: `Detected profile:\n${safeProfile}\n\nScoped project context:\n${sections.join("\n\n")}`,
111
152
  maxTokens: scoped.maxTokens,
112
153
  responseFormat: "json",
113
154
  });
@@ -7,6 +7,6 @@ export type LockedPackage = {
7
7
  integrity: string;
8
8
  permissions?: string[];
9
9
  };
10
- export declare function resolvePackage(pkg: PackageContract): LockedPackage;
10
+ export declare function resolvePackage(pkg: PackageContract, integrity: string): LockedPackage;
11
11
  export declare function serializeLock(packages: LockedPackage[]): string;
12
12
  export declare function parseLock(content: string): LockedPackage[];
package/dist/lockfile.js CHANGED
@@ -1,10 +1,12 @@
1
- export function resolvePackage(pkg) {
1
+ export function resolvePackage(pkg, integrity) {
2
+ if (!/^sha256-[a-f0-9]{64}$/.test(integrity))
3
+ throw new Error("Resolved package integrity must be a SHA-256 snapshot digest.");
2
4
  return {
3
5
  id: pkg.id,
4
6
  version: pkg.version,
5
7
  provider: pkg.provider,
6
8
  source: pkg.source,
7
- integrity: `sha256-${pkg.id}@${pkg.version}`,
9
+ integrity,
8
10
  permissions: [...pkg.permissions].sort(),
9
11
  };
10
12
  }
@@ -27,6 +29,8 @@ export function parseLock(content) {
27
29
  const provider = requiredLockValue(lines[index + 2], /^ {4}provider: (.+)$/, "provider");
28
30
  const source = requiredLockValue(lines[index + 3], /^ {4}source: (.+)$/, "source");
29
31
  const integrity = requiredLockValue(lines[index + 4], /^ {4}integrity: (.+)$/, "integrity");
32
+ if ((provider === "local" || provider === "git") && !/^sha256-[a-f0-9]{64}$/.test(integrity))
33
+ throw new Error(`Lockfile package integrity is invalid: ${id}`);
30
34
  index += 5;
31
35
  let permissions;
32
36
  if (lines[index]?.startsWith(" permissions:")) {
@@ -1,17 +1,36 @@
1
+ import { type ResourceType } from "./package-contract.js";
1
2
  import type { Target } from "./types.js";
2
3
  export type ResourceMigration = {
3
4
  source: string;
4
5
  destination: string;
6
+ type: ResourceType;
7
+ id: string;
8
+ neutralSource: boolean;
9
+ };
10
+ export type MigrationDiagnostic = {
11
+ path: string;
12
+ reason: string;
13
+ };
14
+ export type ResourceMigrationPlan = {
15
+ migrations: ResourceMigration[];
16
+ diagnostics: MigrationDiagnostic[];
5
17
  };
6
18
  export type MigrationSnapshot = {
7
19
  path: string;
8
20
  existed: boolean;
9
21
  contentBase64: string;
10
22
  };
11
- export declare function planNeutralResourceMigration(neutralRoot: string, relativeFiles: string[], target: Target): ResourceMigration[];
12
- export declare function serializeMigrationPreview(target: Target, migrations: ResourceMigration[], existingDestinations: string[]): string;
23
+ export type MigrationCheckpoint = {
24
+ snapshots: MigrationSnapshot[];
25
+ postMigrationSnapshots?: MigrationSnapshot[];
26
+ };
27
+ export declare function planNeutralResourceMigration(neutralRoot: string, relativeFiles: string[], target: Target): ResourceMigrationPlan;
28
+ export declare function validateNeutralResourceDocument(relativePath: string, bytes: Uint8Array): string | undefined;
29
+ export declare function serializeMigrationPreview(target: Target, migrations: ResourceMigration[], existingDestinations: string[], removals?: string[], diagnostics?: MigrationDiagnostic[], conflicts?: string[], additionalChanges?: string[]): string;
13
30
  export declare function serializeMigrationSnapshots(snapshots: MigrationSnapshot[]): string;
14
31
  export declare function parseMigrationSnapshots(encoded: string): MigrationSnapshot[];
32
+ export declare function serializeMigrationCheckpoint(snapshots: MigrationSnapshot[], postMigrationSnapshots?: MigrationSnapshot[]): string;
33
+ export declare function parseMigrationCheckpoint(content: string): MigrationCheckpoint;
15
34
  export declare function readResourceBytes(fs: {
16
35
  read(path: string): string;
17
36
  readBytes?(path: string): Uint8Array;
package/dist/migration.js CHANGED
@@ -1,43 +1,184 @@
1
- import { join } from "node:path";
1
+ import { parseDocument } from "yaml";
2
+ import { isAbsolute, join } from "node:path";
2
3
  import { renderResourcePath } from "./adapter.js";
3
4
  import { RESOURCE_TYPES } from "./package-contract.js";
4
5
  export function planNeutralResourceMigration(neutralRoot, relativeFiles, target) {
5
- return relativeFiles
6
- .flatMap((relative) => {
7
- const [candidateType, id, filename, extra] = relative.split("/");
8
- if (extra || !RESOURCE_TYPES.includes(candidateType) || !id || !filename)
9
- return [];
6
+ const migrations = [];
7
+ const diagnostics = [];
8
+ for (const relative of relativeFiles) {
9
+ const parts = relative.split("/");
10
+ const [candidateType, id, filename, extra] = parts;
11
+ if (isAbsolute(relative) ||
12
+ relative.includes("\\") ||
13
+ parts.some((part) => !part || part === "." || part === "..")) {
14
+ diagnostics.push({ path: relative, reason: "path is not normalized and project-relative" });
15
+ continue;
16
+ }
17
+ if (extra ||
18
+ parts.length !== 3 ||
19
+ !RESOURCE_TYPES.includes(candidateType)) {
20
+ diagnostics.push({ path: relative, reason: "unsupported neutral resource path" });
21
+ continue;
22
+ }
23
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
24
+ diagnostics.push({ path: relative, reason: "resource identifier is invalid" });
25
+ continue;
26
+ }
10
27
  const type = candidateType;
11
- const expected = type === "skills" ? "SKILL.md" : `${id}.md`;
12
- if (filename !== expected)
13
- return [];
14
- return [
15
- { source: join(neutralRoot, relative), destination: renderResourcePath(target, type, id) },
16
- ];
17
- })
18
- .sort((left, right) => left.destination.localeCompare(right.destination));
19
- }
20
- export function serializeMigrationPreview(target, migrations, existingDestinations) {
28
+ if (type === "skills" && id === "ai-init") {
29
+ diagnostics.push({
30
+ path: relative,
31
+ reason: "skill id ai-init is reserved for the bootstrap skill",
32
+ });
33
+ continue;
34
+ }
35
+ const expectedFilename = type === "skills" ? "SKILL.md" : `${id}.md`;
36
+ if (filename !== expectedFilename) {
37
+ diagnostics.push({ path: relative, reason: `expected filename ${expectedFilename}` });
38
+ continue;
39
+ }
40
+ migrations.push({
41
+ source: join(neutralRoot, relative),
42
+ destination: renderResourcePath(target, type, id),
43
+ type,
44
+ id,
45
+ neutralSource: target === "universal",
46
+ });
47
+ }
48
+ migrations.sort((left, right) => left.destination < right.destination ? -1 : left.destination > right.destination ? 1 : 0);
49
+ diagnostics.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
50
+ return { migrations, diagnostics };
51
+ }
52
+ export function validateNeutralResourceDocument(relativePath, bytes) {
53
+ let content;
54
+ try {
55
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
56
+ }
57
+ catch {
58
+ return "resource is not valid UTF-8";
59
+ }
60
+ if ([...content].some((character) => {
61
+ const code = character.codePointAt(0) ?? 0;
62
+ return (code <= 0x08 || (code >= 0x0e && code <= 0x1f)) && code !== 0x09;
63
+ }))
64
+ return "resource contains unsupported control characters";
65
+ let body = content.replace(/^\uFEFF/, "");
66
+ if (body.startsWith("---\n") || body.startsWith("---\r\n")) {
67
+ const frontmatter = body.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
68
+ if (!frontmatter)
69
+ return "resource frontmatter is not closed";
70
+ const document = parseDocument(frontmatter[1], { uniqueKeys: true, strict: true });
71
+ if (document.errors.length)
72
+ return "resource frontmatter is malformed";
73
+ const value = document.toJS();
74
+ if (!value || typeof value !== "object" || Array.isArray(value))
75
+ return "resource frontmatter must be a mapping";
76
+ const metadata = value;
77
+ if (typeof metadata.name !== "string" || !metadata.name.trim())
78
+ return "resource frontmatter requires a non-empty name";
79
+ if (typeof metadata.description !== "string" || !metadata.description.trim())
80
+ return "resource frontmatter requires a non-empty description";
81
+ body = body.slice(frontmatter[0].length);
82
+ }
83
+ if (!/^#{1,6}\s+\S/m.test(body))
84
+ return "resource must contain a Markdown heading";
85
+ if (!relativePath.endsWith(".md"))
86
+ return "resource must use the .md format";
87
+ return undefined;
88
+ }
89
+ export function serializeMigrationPreview(target, migrations, existingDestinations, removals = [], diagnostics = [], conflicts = [], additionalChanges = []) {
21
90
  const existing = new Set(existingDestinations);
22
- const changes = migrations.map(({ destination }) => existing.has(destination) ? `update: ${destination}` : `add: ${destination}`);
23
- return `Migration preview for ${target}:\n${changes.join("\n")}${changes.length ? "\n" : ""}remove: previous target ai-init\n`;
91
+ const changes = migrations.map(({ destination, neutralSource }) => neutralSource
92
+ ? `keep: ${destination} (neutral source)`
93
+ : existing.has(destination)
94
+ ? `update: ${destination}`
95
+ : `add: ${destination}`);
96
+ changes.push(...removals.map((path) => `remove: ${path}`));
97
+ changes.push(...conflicts.map((path) => `conflict: ${path}`));
98
+ changes.push(...diagnostics.map(({ path, reason }) => `invalid: ${path} (${reason})`));
99
+ changes.push(...additionalChanges);
100
+ return `Migration preview for ${target}:\n${changes.join("\n")}${changes.length ? "\n" : ""}`;
24
101
  }
25
102
  export function serializeMigrationSnapshots(snapshots) {
103
+ snapshots.forEach(validateSnapshot);
104
+ if (new Set(snapshots.map(({ path }) => path)).size !== snapshots.length)
105
+ throw new Error("Migration resource snapshots contain duplicate paths.");
26
106
  return Buffer.from(JSON.stringify(snapshots), "utf8").toString("base64");
27
107
  }
28
108
  export function parseMigrationSnapshots(encoded) {
29
- const value = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
109
+ let value;
110
+ try {
111
+ value = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
112
+ }
113
+ catch {
114
+ throw new Error("Migration resource snapshots are invalid.");
115
+ }
30
116
  if (!Array.isArray(value))
31
117
  throw new Error("Migration resource snapshots are invalid.");
32
- return value.map((snapshot) => {
118
+ const snapshots = value.map((snapshot) => {
33
119
  if (typeof snapshot !== "object" ||
34
120
  snapshot === null ||
35
121
  typeof snapshot.path !== "string" ||
36
122
  typeof snapshot.existed !== "boolean" ||
37
123
  typeof snapshot.contentBase64 !== "string")
38
124
  throw new Error("Migration resource snapshots are invalid.");
39
- return snapshot;
125
+ const candidate = snapshot;
126
+ if (Object.keys(snapshot).some((key) => !["path", "existed", "contentBase64"].includes(key)))
127
+ throw new Error("Migration resource snapshots contain unsupported fields.");
128
+ validateSnapshot(candidate);
129
+ return candidate;
40
130
  });
131
+ if (new Set(snapshots.map(({ path }) => path)).size !== snapshots.length)
132
+ throw new Error("Migration resource snapshots contain duplicate paths.");
133
+ return snapshots;
134
+ }
135
+ export function serializeMigrationCheckpoint(snapshots, postMigrationSnapshots) {
136
+ if (!postMigrationSnapshots)
137
+ return `schema: 2\nsnapshots_base64: ${serializeMigrationSnapshots(snapshots)}\n`;
138
+ return `schema: 3\nsnapshots_base64: ${serializeMigrationSnapshots(snapshots)}\npost_migration_snapshots_base64: ${serializeMigrationSnapshots(postMigrationSnapshots)}\n`;
139
+ }
140
+ export function parseMigrationCheckpoint(content) {
141
+ const document = parseDocument(content, { uniqueKeys: true, strict: true });
142
+ if (document.errors.length)
143
+ throw new Error("Migration backup is invalid.");
144
+ const value = document.toJS();
145
+ if (!value || typeof value !== "object" || Array.isArray(value))
146
+ throw new Error("Migration backup is invalid.");
147
+ const checkpoint = value;
148
+ if (checkpoint.schema === 2 &&
149
+ Object.keys(checkpoint).length === 2 &&
150
+ typeof checkpoint.snapshots_base64 === "string")
151
+ return { snapshots: parseMigrationSnapshots(checkpoint.snapshots_base64) };
152
+ if (checkpoint.schema === 3 &&
153
+ Object.keys(checkpoint).length === 3 &&
154
+ typeof checkpoint.snapshots_base64 === "string" &&
155
+ typeof checkpoint.post_migration_snapshots_base64 === "string")
156
+ return {
157
+ snapshots: parseMigrationSnapshots(checkpoint.snapshots_base64),
158
+ postMigrationSnapshots: parseMigrationSnapshots(checkpoint.post_migration_snapshots_base64),
159
+ };
160
+ throw new Error("Migration backup is invalid.");
161
+ }
162
+ function validateSnapshotPath(path) {
163
+ if (!path ||
164
+ isAbsolute(path) ||
165
+ path.includes("\\") ||
166
+ path.includes(":") ||
167
+ [...path].some((character) => {
168
+ const code = character.codePointAt(0) ?? 0;
169
+ return code <= 0x1f || code === 0x7f;
170
+ }) ||
171
+ path.split("/").some((part) => !part || part === "." || part === ".."))
172
+ throw new Error("Migration snapshot path must be project-relative.");
173
+ }
174
+ function validateSnapshot(snapshot) {
175
+ validateSnapshotPath(snapshot.path);
176
+ if (typeof snapshot.existed !== "boolean")
177
+ throw new Error("Migration resource snapshots are invalid.");
178
+ if (!snapshot.existed && snapshot.contentBase64 !== "")
179
+ throw new Error("Migration resource snapshots are invalid.");
180
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(snapshot.contentBase64))
181
+ throw new Error("Migration resource snapshots are invalid.");
41
182
  }
42
183
  export function readResourceBytes(fs, path) {
43
184
  return fs.readBytes?.(path) ?? Buffer.from(fs.read(path), "utf8");
@@ -0,0 +1,14 @@
1
+ import { type ResourceType } from "./package-contract.js";
2
+ import { type Target } from "./types.js";
3
+ export type OwnedFile = {
4
+ path: string;
5
+ checksum: string;
6
+ kind?: "neutral-resource" | "target-resource" | "ai-init" | "supporting-file";
7
+ target?: Target;
8
+ resourceType?: ResourceType;
9
+ resourceId?: string;
10
+ };
11
+ export type OwnershipMetadata = Omit<OwnedFile, "path" | "checksum">;
12
+ export declare function createOwnedFile(path: string, content: string | Uint8Array, metadata?: OwnershipMetadata): OwnedFile;
13
+ export declare function serializeOwnership(files: OwnedFile[]): string;
14
+ export declare function parseOwnership(content: string): OwnedFile[];