@punal_manalan/blender-project-harness 0.0.0-stage → 1.0.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/.kilo/agent/blender-grader.md +197 -0
- package/.kilo/agent/blender-worker.md +127 -0
- package/.kilo/blender-project-harness/config.example.json +71 -0
- package/.kilo/blender-project-harness/tools/harness/EXPECTED_FLOW.md +656 -0
- package/.kilo/blender-project-harness/tools/harness/blender-operations.mjs +882 -0
- package/.kilo/blender-project-harness/tools/harness/blender-operations.test.mjs +550 -0
- package/.kilo/blender-project-harness/tools/harness/blender.mjs +301 -0
- package/.kilo/blender-project-harness/tools/harness/blender.test.mjs +87 -0
- package/.kilo/blender-project-harness/tools/harness/cli.mjs +127 -0
- package/.kilo/blender-project-harness/tools/harness/documents.mjs +201 -0
- package/.kilo/blender-project-harness/tools/harness/download.mjs +94 -0
- package/.kilo/blender-project-harness/tools/harness/download.test.mjs +63 -0
- package/.kilo/blender-project-harness/tools/harness/engine.mjs +1866 -0
- package/.kilo/blender-project-harness/tools/harness/engine.test.mjs +2219 -0
- package/.kilo/blender-project-harness/tools/harness/intake.mjs +571 -0
- package/.kilo/blender-project-harness/tools/harness/intake.test.mjs +181 -0
- package/.kilo/blender-project-harness/tools/harness/package-lock.json +1227 -0
- package/.kilo/blender-project-harness/tools/harness/package.json +15 -0
- package/.kilo/blender-project-harness/tools/harness/plugin.mjs +550 -0
- package/.kilo/blender-project-harness/tools/harness/plugin.test.mjs +526 -0
- package/.kilo/blender-project-harness/tools/harness/proxy.mjs +222 -0
- package/.kilo/blender-project-harness/tools/harness/proxy.test.mjs +217 -0
- package/.kilo/blender-project-harness/tools/harness/scaffold.mjs +83 -0
- package/.kilo/blender-project-harness/tools/harness/scaffold.test.mjs +74 -0
- package/.kilo/blender-project-harness/tools/harness/server.mjs +24 -0
- package/.kilo/blender-project-harness/tools/harness/shell-policy.mjs +196 -0
- package/.kilo/blender-project-harness/tools/harness/shell-policy.test.mjs +68 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/ASSETS.md +29 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/DECISIONS.md +8 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/01-project-brief.md +71 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/README.md +17 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/INTAKE.md +8 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/README.md +57 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REQUIREMENTS.md +64 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REVIEW.md +29 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/References/README.md +18 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Reviews/README.md +48 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/TASKSTRACKER.md +10 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Tasks/README.md +4 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/WORKFLOW.md +36 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/README.md +130 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/README.md +31 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/audio/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/README.md +43 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/SOURCE_TEMPLATE.md +18 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/hdri/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/models/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/textures/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/backups/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/backups/README.md +5 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/exports/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/exports/README.md +11 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/README.md +7 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/final/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/wip/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/scenes/README.md +10 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/scripts/README.md +16 -0
- package/.kilo/blender-project-harness/tools/harness/test-fixture.mjs +91 -0
- package/.kilo/blender-project-harness/tools/harness/workspace.mjs +30 -0
- package/.kilo/blender-project-harness/tools/harness/workspace.test.mjs +31 -0
- package/.kilo/blender-project-harness/tools/test_blender_tools.py +427 -0
- package/.kilo/plugins/blender-workflow.js +3 -0
- package/.kilo/skills/blender-assets/SKILL.md +99 -0
- package/.kilo/skills/blender-mcp/SKILL.md +147 -0
- package/.kilo/skills/blender-mcp/references/README.md +22 -0
- package/.kilo/skills/blender-mcp/references/animation.md +83 -0
- package/.kilo/skills/blender-mcp/references/bpy.md +97 -0
- package/.kilo/skills/blender-mcp/references/level-design.md +53 -0
- package/.kilo/skills/blender-mcp/references/materials.md +80 -0
- package/.kilo/skills/blender-mcp/references/retopology.md +104 -0
- package/.kilo/skills/blender-mcp/references/rigging.md +123 -0
- package/.kilo/skills/blender-mcp/references/scene.md +72 -0
- package/.kilo/skills/blender-project-goal/SKILL.md +115 -0
- package/.kilo/skills/blender-project-grading/SKILL.md +184 -0
- package/.kilo/skills/blender-project-readme/SKILL.md +46 -0
- package/.kilo/skills/blender-project-requirements/SKILL.md +84 -0
- package/.kilo/skills/blender-project-scaffold/SKILL.md +59 -0
- package/.kilo/skills/blender-project-workflow/SKILL.md +96 -0
- package/.kilo/skills/blender-subagents/SKILL.md +204 -0
- package/.kilo/skills/blender-workflow-harness/SKILL.md +187 -0
- package/AGENTS.md +234 -0
- package/Blender/README.md +30 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +368 -2
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/bin/blender-project-harness.mjs +4 -0
- package/installer/cli.mjs +116 -0
- package/installer/install.mjs +325 -0
- package/installer/install.test.mjs +134 -0
- package/package.json +58 -6
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
const README_SECTIONS = [
|
|
2
|
+
'Purpose',
|
|
3
|
+
'What this project is',
|
|
4
|
+
'Requirements',
|
|
5
|
+
'Visual direction',
|
|
6
|
+
'Deliverables',
|
|
7
|
+
'Project structure',
|
|
8
|
+
'Blender',
|
|
9
|
+
'Workflow',
|
|
10
|
+
'External assets',
|
|
11
|
+
'Goals',
|
|
12
|
+
'References',
|
|
13
|
+
'Open questions',
|
|
14
|
+
'Next steps',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const GOAL_SECTIONS = [
|
|
18
|
+
'Mission',
|
|
19
|
+
'Project',
|
|
20
|
+
'Visual outcome',
|
|
21
|
+
'Scope',
|
|
22
|
+
'Deliverables',
|
|
23
|
+
'Acceptance',
|
|
24
|
+
'Workflow',
|
|
25
|
+
'Done',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const DOCUMENT_FORMATS = Object.freeze([
|
|
29
|
+
{
|
|
30
|
+
id: 'readme',
|
|
31
|
+
path: 'README.md',
|
|
32
|
+
fields: ['Status', 'Last updated'],
|
|
33
|
+
sections: README_SECTIONS,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'requirements',
|
|
37
|
+
path: 'Documents/REQUIREMENTS.md',
|
|
38
|
+
fields: ['Status', 'Defined', 'Last updated', 'Confirmed', 'Confirmation'],
|
|
39
|
+
sections: ['Core value', 'Confirmed requirements', 'Constraints', 'Deferred', 'Out of scope', 'Open questions', 'Research notes', 'Change log'],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'workflow',
|
|
43
|
+
path: 'Documents/WORKFLOW.md',
|
|
44
|
+
fields: ['Mode', 'Selected', 'Last changed'],
|
|
45
|
+
sections: ['Current mode', 'Rules', 'Change log'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'review',
|
|
49
|
+
path: 'Documents/REVIEW.md',
|
|
50
|
+
fields: ['Mode', 'Selected', 'Last changed', 'Single grader'],
|
|
51
|
+
sections: ['Modes', 'Change log'],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'assets',
|
|
55
|
+
path: 'Documents/ASSETS.md',
|
|
56
|
+
fields: [],
|
|
57
|
+
sections: ['Rules'],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'decisions',
|
|
61
|
+
path: 'Documents/DECISIONS.md',
|
|
62
|
+
fields: [],
|
|
63
|
+
sections: [],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'task-tracker',
|
|
67
|
+
path: 'Documents/TASKSTRACKER.md',
|
|
68
|
+
fields: ['Status', 'Last updated'],
|
|
69
|
+
sections: ['Tasks'],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'goal',
|
|
73
|
+
pattern: /^Documents\/Goals\/\d+-[a-z0-9-]+\.md$/,
|
|
74
|
+
fields: [],
|
|
75
|
+
sections: GOAL_SECTIONS,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'documents-index',
|
|
79
|
+
path: 'Documents/README.md',
|
|
80
|
+
fields: [],
|
|
81
|
+
sections: ['Rules'],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'reviews-index',
|
|
85
|
+
path: 'Documents/Reviews/README.md',
|
|
86
|
+
fields: [],
|
|
87
|
+
sections: ['Index', 'Rules'],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'references-index',
|
|
91
|
+
path: 'Documents/References/README.md',
|
|
92
|
+
fields: [],
|
|
93
|
+
sections: ['Index'],
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
export function documentFormat(relative) {
|
|
98
|
+
return DOCUMENT_FORMATS.find((format) => format.path === relative || format.pattern?.test(relative)) ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isBootstrapDocument(relative) {
|
|
102
|
+
if (typeof relative !== 'string' || !relative.endsWith('.md')) return false;
|
|
103
|
+
if (relative.startsWith('Documents/Reviews/') && relative !== 'Documents/Reviews/README.md') return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function describeDocumentFormats() {
|
|
108
|
+
return DOCUMENT_FORMATS.map((format) => ({
|
|
109
|
+
id: format.id,
|
|
110
|
+
path: format.path ?? String(format.pattern),
|
|
111
|
+
fields: [...format.fields],
|
|
112
|
+
sections: [...format.sections],
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const field = (text, key) => [...text.matchAll(new RegExp(`^${key}:[ \\t]*([^\\r\\n]*)`, 'gm'))];
|
|
117
|
+
const normalized = (content) => content.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
118
|
+
|
|
119
|
+
function outline(relative, content) {
|
|
120
|
+
const text = normalized(content);
|
|
121
|
+
const lines = text.split('\n');
|
|
122
|
+
const title = /^#(?!#)[ \t]+(.+?)[ \t]*$/m.exec(text)?.[1] ?? null;
|
|
123
|
+
const fields = {};
|
|
124
|
+
const sections = [];
|
|
125
|
+
let current = null;
|
|
126
|
+
for (let index = 0; index < lines.length; index++) {
|
|
127
|
+
const line = lines[index];
|
|
128
|
+
const heading = /^##[ \t]+(.+?)[ \t]*$/.exec(line);
|
|
129
|
+
if (heading) {
|
|
130
|
+
current = { heading: heading[1], level: 2, line: index + 1, lines: [] };
|
|
131
|
+
sections.push(current);
|
|
132
|
+
} else if (current) current.lines.push(line);
|
|
133
|
+
else {
|
|
134
|
+
const match = /^([A-Za-z][A-Za-z0-9 _-]*):[ \t]*(.*)$/.exec(line.trim());
|
|
135
|
+
if (match) fields[match[1]] = match[2];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const diagnostics = [];
|
|
139
|
+
try { validateDocument(relative, content); } catch (error) { diagnostics.push(error.message); }
|
|
140
|
+
return {
|
|
141
|
+
title,
|
|
142
|
+
format: documentFormat(relative)?.id ?? null,
|
|
143
|
+
fields,
|
|
144
|
+
sections: sections.map((section) => {
|
|
145
|
+
const body = section.lines.filter((line) => line.trim());
|
|
146
|
+
const subheadings = body.map((line) => /^###[ \t]+(.+?)[ \t]*$/.exec(line)?.[1]).filter(Boolean);
|
|
147
|
+
const excerpt = body.find((line) => !line.startsWith('#'))?.trim().slice(0, 200) ?? '';
|
|
148
|
+
return { heading: section.heading, line: section.line, lines: body.length, subheadings, excerpt };
|
|
149
|
+
}),
|
|
150
|
+
diagnostics,
|
|
151
|
+
valid: diagnostics.length === 0,
|
|
152
|
+
bytes: content.length,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function documentOutline(relative, content) {
|
|
157
|
+
if (typeof content !== 'string') throw new Error(`document gate: ${relative} content must be markdown text`);
|
|
158
|
+
return outline(relative, content);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function documentSection(relative, content, heading) {
|
|
162
|
+
if (typeof content !== 'string') throw new Error(`document gate: ${relative} content must be markdown text`);
|
|
163
|
+
const text = normalized(content);
|
|
164
|
+
const lines = text.split('\n');
|
|
165
|
+
const start = lines.findIndex((line) => new RegExp(`^##[ \\t]+${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[ \\t]*$`, 'i').test(line));
|
|
166
|
+
if (start < 0) return null;
|
|
167
|
+
const end = lines.findIndex((line, index) => index > start && /^##[ \t]+/.test(line));
|
|
168
|
+
const body = lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim();
|
|
169
|
+
return { heading: lines[start].replace(/^##[ \t]+/, '').trim(), line: start + 1, content: body };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function validateDocument(relative, content) {
|
|
173
|
+
if (typeof content !== 'string') throw new Error(`document gate: ${relative} content must be markdown text`);
|
|
174
|
+
if (!content.trim()) throw new Error(`document gate: ${relative} is empty`);
|
|
175
|
+
if (content.length > 1_000_000) throw new Error(`document gate: ${relative} exceeds 1,000,000 characters`);
|
|
176
|
+
const text = normalized(content);
|
|
177
|
+
if (text.includes('{{')) throw new Error(`document gate: ${relative} contains unresolved template tokens`);
|
|
178
|
+
|
|
179
|
+
const headings = [...text.matchAll(/^#(?!#)[ \t]+(.+)$/gm)];
|
|
180
|
+
if (headings.length !== 1) throw new Error(`document gate: ${relative} requires exactly one level-1 heading`);
|
|
181
|
+
|
|
182
|
+
const format = documentFormat(relative);
|
|
183
|
+
if (!format) return { format: null };
|
|
184
|
+
for (const name of format.fields) {
|
|
185
|
+
const matches = field(text, name);
|
|
186
|
+
if (matches.length !== 1 || !matches[0][1].trim()) throw new Error(`document gate: ${relative} requires exactly one filled ${name}: field`);
|
|
187
|
+
}
|
|
188
|
+
for (const name of format.sections) {
|
|
189
|
+
const count = [...text.matchAll(new RegExp(`^##[ \\t]+${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[ \\t]*$`, 'gm'))].length;
|
|
190
|
+
if (count !== 1) throw new Error(`document gate: ${relative} requires exactly one "## ${name}" section`);
|
|
191
|
+
}
|
|
192
|
+
return { format: format.id };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const BOOTSTRAP_FILE = /(?:^|\/)(?:\.gitkeep|\.gitignore|\.gitattributes|README\.md|SOURCE\.md|SOURCE_TEMPLATE\.md|LICENSE(?:\.[A-Za-z0-9._-]+)?)$/i;
|
|
196
|
+
|
|
197
|
+
export function isBootstrapFile(relative) {
|
|
198
|
+
if (typeof relative !== 'string' || !relative) return false;
|
|
199
|
+
if (relative.endsWith('.md')) return isBootstrapDocument(relative);
|
|
200
|
+
return BOOTSTRAP_FILE.test(relative);
|
|
201
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { classifyIntakeFile, safeIntakeFileName } from './intake.mjs';
|
|
5
|
+
|
|
6
|
+
export const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
|
|
7
|
+
const MAX_REDIRECTS = 5;
|
|
8
|
+
|
|
9
|
+
function fail(message) {
|
|
10
|
+
throw new Error(message);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function privateHost(hostname) {
|
|
14
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
15
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host === '::1' || host === '0.0.0.0') return true;
|
|
16
|
+
if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^169\.254\./.test(host)) return true;
|
|
17
|
+
const match = /^172\.(\d+)\./.exec(host);
|
|
18
|
+
if (match && Number(match[1]) >= 16 && Number(match[1]) <= 31) return true;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateDownloadUrl(value) {
|
|
23
|
+
let url;
|
|
24
|
+
try { url = new URL(value); } catch { fail('download URL is invalid'); }
|
|
25
|
+
if (url.protocol !== 'https:') fail('download URL must use HTTPS');
|
|
26
|
+
if (url.username || url.password) fail('download URL cannot contain credentials');
|
|
27
|
+
if (privateHost(url.hostname)) fail('download URL cannot target a private or local host');
|
|
28
|
+
return url;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function downloadFileName(url, filename) {
|
|
32
|
+
let name = filename;
|
|
33
|
+
if (!name) {
|
|
34
|
+
const pathname = new URL(url).pathname;
|
|
35
|
+
name = decodeURIComponent(pathname.split('/').filter(Boolean).at(-1) ?? 'download');
|
|
36
|
+
}
|
|
37
|
+
if (!classifyIntakeFile(name)) fail('download filename must be a supported asset, reference, script, or image type');
|
|
38
|
+
return safeIntakeFileName(name, 'download');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function headerFileName(header) {
|
|
42
|
+
if (!header) return null;
|
|
43
|
+
const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(header);
|
|
44
|
+
return match ? decodeURIComponent(match[1].replace(/"/g, '')) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stageDirectory(root, project) {
|
|
48
|
+
const key = String(project ?? 'workspace').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'workspace';
|
|
49
|
+
return path.join(root, '.temp', 'Intake', key, `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function downloadToStaging({ root, project, url, filename, maxBytes = MAX_DOWNLOAD_BYTES } = {}) {
|
|
53
|
+
const current = validateDownloadUrl(url);
|
|
54
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DOWNLOAD_BYTES) fail(`maxBytes must be between 1 and ${MAX_DOWNLOAD_BYTES}`);
|
|
55
|
+
let response;
|
|
56
|
+
let target = current;
|
|
57
|
+
for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
|
|
58
|
+
response = await fetch(target, {
|
|
59
|
+
redirect: 'manual',
|
|
60
|
+
signal: AbortSignal.timeout(120_000),
|
|
61
|
+
headers: { 'user-agent': 'blender-workflow-harness/1.0' },
|
|
62
|
+
});
|
|
63
|
+
if (response.status >= 300 && response.status < 400) {
|
|
64
|
+
const location = response.headers.get('location');
|
|
65
|
+
if (!location) fail('download redirect has no location');
|
|
66
|
+
target = validateDownloadUrl(new URL(location, target).toString());
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
if (!response || response.status < 200 || response.status >= 300) fail(`download failed with status ${response?.status ?? 'unknown'}`);
|
|
72
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
73
|
+
if (/^text\/html\b/i.test(contentType) || /^application\/json\b/i.test(contentType)) fail('download returned a document, not an asset');
|
|
74
|
+
const length = Number(response.headers.get('content-length') ?? 0);
|
|
75
|
+
if (Number.isFinite(length) && length > maxBytes) fail(`download exceeds maxBytes (${length} > ${maxBytes})`);
|
|
76
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
77
|
+
if (buffer.byteLength > maxBytes) fail(`download exceeds maxBytes (${buffer.byteLength} > ${maxBytes})`);
|
|
78
|
+
const chosen = downloadFileName(target.toString(), filename ?? headerFileName(response.headers.get('content-disposition')));
|
|
79
|
+
const directory = stageDirectory(root, project);
|
|
80
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
81
|
+
const file = path.join(directory, chosen);
|
|
82
|
+
fs.writeFileSync(file, buffer, { mode: 0o600 });
|
|
83
|
+
return {
|
|
84
|
+
project: project ?? null,
|
|
85
|
+
url: target.toString(),
|
|
86
|
+
path: file,
|
|
87
|
+
relative: path.relative(root, file).replaceAll('\\', '/'),
|
|
88
|
+
filename: chosen,
|
|
89
|
+
bytes: buffer.byteLength,
|
|
90
|
+
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
|
91
|
+
contentType,
|
|
92
|
+
next: 'Call blender_workflow intake with this path and a source/licence record; download alone does not import or execute.',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { downloadFileName, downloadToStaging, validateDownloadUrl } from './download.mjs';
|
|
7
|
+
|
|
8
|
+
test('download rejects insecure and private URLs', () => {
|
|
9
|
+
assert.throws(() => validateDownloadUrl('http://example.com/chair.glb'), /HTTPS/);
|
|
10
|
+
assert.throws(() => validateDownloadUrl('https://localhost/chair.glb'), /private/);
|
|
11
|
+
assert.throws(() => validateDownloadUrl('https://127.0.0.1/chair.glb'), /private/);
|
|
12
|
+
assert.throws(() => validateDownloadUrl('https://10.1.2.3/chair.glb'), /private/);
|
|
13
|
+
assert.throws(() => validateDownloadUrl('file:///C:/chair.glb'), /HTTPS/);
|
|
14
|
+
assert.throws(() => validateDownloadUrl('https://user:secret@example.com/chair.glb'), /credentials/);
|
|
15
|
+
assert.doesNotThrow(() => validateDownloadUrl('https://example.com/chair.glb'));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('download names only supported files', () => {
|
|
19
|
+
assert.equal(downloadFileName('https://example.com/files/mood%20board.png'), 'mood-board.png');
|
|
20
|
+
assert.equal(downloadFileName('https://example.com/download', 'chair.glb'), 'chair.glb');
|
|
21
|
+
assert.throws(() => downloadFileName('https://example.com/page.html'), /supported/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('download stages bytes with a hash and no project write', async (t) => {
|
|
25
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-download-'));
|
|
26
|
+
t.after(() => fs.rmSync(root, { force: true, recursive: true }));
|
|
27
|
+
const original = globalThis.fetch;
|
|
28
|
+
globalThis.fetch = async () => new Response(Buffer.from('glb-bytes'), {
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: { 'content-type': 'application/octet-stream', 'content-length': '9' },
|
|
31
|
+
});
|
|
32
|
+
t.after(() => { globalThis.fetch = original; });
|
|
33
|
+
const result = await downloadToStaging({
|
|
34
|
+
root,
|
|
35
|
+
project: 'Blender/Test',
|
|
36
|
+
url: 'https://example.com/chair.glb',
|
|
37
|
+
filename: 'chair.glb',
|
|
38
|
+
});
|
|
39
|
+
assert.equal(result.bytes, 9);
|
|
40
|
+
assert.equal(result.sha256, '73b18949c3b005215fc7c63efc8c29d5b31ce0760ca9a2f60396cc41b12e970f');
|
|
41
|
+
assert.match(result.relative, /^\.temp\/Intake\/Blender-Test\//);
|
|
42
|
+
assert.equal(fs.readFileSync(result.path, 'utf8'), 'glb-bytes');
|
|
43
|
+
assert.equal(fs.existsSync(path.join(root, 'Blender', 'Test')), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('download refuses oversized responses before staging', async (t) => {
|
|
47
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-download-limit-'));
|
|
48
|
+
t.after(() => fs.rmSync(root, { force: true, recursive: true }));
|
|
49
|
+
const original = globalThis.fetch;
|
|
50
|
+
globalThis.fetch = async () => new Response(Buffer.from('glb-bytes'), {
|
|
51
|
+
status: 200,
|
|
52
|
+
headers: { 'content-type': 'application/octet-stream', 'content-length': '9' },
|
|
53
|
+
});
|
|
54
|
+
t.after(() => { globalThis.fetch = original; });
|
|
55
|
+
await assert.rejects(downloadToStaging({
|
|
56
|
+
root,
|
|
57
|
+
project: 'Blender/Test',
|
|
58
|
+
url: 'https://example.com/chair.glb',
|
|
59
|
+
filename: 'chair.glb',
|
|
60
|
+
maxBytes: 4,
|
|
61
|
+
}), /maxBytes/);
|
|
62
|
+
assert.equal(fs.existsSync(path.join(root, '.temp', 'Intake')), false);
|
|
63
|
+
});
|