@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/CHANGELOG.md +29 -1
- package/README.md +316 -13
- package/dist/adapter.d.ts +1 -0
- package/dist/adapter.js +37 -1
- package/dist/bundled-resources.d.ts +9 -0
- package/dist/bundled-resources.js +29 -0
- package/dist/constants.js +1 -0
- package/dist/files.d.ts +10 -0
- package/dist/files.js +216 -7
- package/dist/generator.js +13 -3
- package/dist/interpreter.js +47 -6
- package/dist/lockfile.d.ts +1 -1
- package/dist/lockfile.js +6 -2
- package/dist/migration.d.ts +21 -2
- package/dist/migration.js +162 -21
- package/dist/ownership.d.ts +14 -0
- package/dist/ownership.js +83 -0
- package/dist/package-audit.d.ts +1 -0
- package/dist/package-audit.js +7 -3
- package/dist/package-contract.d.ts +1 -0
- package/dist/package-contract.js +136 -74
- package/dist/package-integrity.d.ts +5 -1
- package/dist/package-integrity.js +56 -1
- package/dist/providers.d.ts +2 -0
- package/dist/providers.js +27 -5
- package/dist/recommendations.d.ts +7 -0
- package/dist/recommendations.js +141 -10
- package/dist/scanner.js +28 -26
- package/dist/types.d.ts +2 -0
- package/dist/workflow.js +729 -147
- package/docs/adapter-model.md +3 -1
- package/docs/backlog.md +2 -2
- package/docs/package-model.md +5 -3
- package/docs/product-requirements.md +2 -0
- package/docs/project-intelligence.md +3 -1
- package/docs/release-backlog.md +1 -1
- package/docs/security.md +4 -0
- package/package.json +9 -5
- package/resources/package.yaml +37 -0
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
+
}
|
package/dist/interpreter.js
CHANGED
|
@@ -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]
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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${
|
|
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
|
});
|
package/dist/lockfile.d.ts
CHANGED
|
@@ -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
|
|
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:")) {
|
package/dist/migration.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
|
|
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 {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 }) =>
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[];
|