@stage5/lumine 0.2.3 → 0.2.5
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/bin/lumine.js +1 -2841
- package/lib/api.js +381 -0
- package/lib/assets.js +670 -0
- package/lib/auth.js +217 -0
- package/lib/commands.js +1491 -0
- package/lib/constants.js +256 -0
- package/lib/http.js +72 -0
- package/lib/sdk.js +489 -0
- package/lib/util.js +154 -0
- package/lib/workspace.js +577 -0
- package/package.json +5 -1
package/lib/workspace.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AGENT_INSTRUCTION_FILES,
|
|
6
|
+
BUNDLED_SDK_REFERENCE_URL,
|
|
7
|
+
EXCLUDED_UPLOAD_DIRS,
|
|
8
|
+
EXCLUDED_UPLOAD_FILES,
|
|
9
|
+
LUMINE_AGENT_INSTRUCTIONS,
|
|
10
|
+
LUMINE_AGENT_INSTRUCTIONS_MARKER,
|
|
11
|
+
LUMINE_REFERENCE_INSTRUCTIONS,
|
|
12
|
+
LUMINE_REFERENCE_INSTRUCTIONS_MARKER,
|
|
13
|
+
LUMINE_SDK_REFERENCE_MARKER,
|
|
14
|
+
PROJECT_EFFECTIVE_LINE_MAX_COLUMNS,
|
|
15
|
+
PROJECT_MAX_EFFECTIVE_FILE_LINES,
|
|
16
|
+
PROJECT_MAX_FILES,
|
|
17
|
+
PROJECT_MAX_TOTAL_BYTES_DEFAULT,
|
|
18
|
+
PROJECT_METADATA_DIR,
|
|
19
|
+
PROJECT_METADATA_FILE,
|
|
20
|
+
SDK_REFERENCE_FALLBACK,
|
|
21
|
+
SDK_REFERENCE_FILE,
|
|
22
|
+
} from "./constants.js";
|
|
23
|
+
import { serializeLumineCliMetadata } from "./api.js";
|
|
24
|
+
import { formatBytes } from "./util.js";
|
|
25
|
+
|
|
26
|
+
export async function writeAgentInstructions({ dir }) {
|
|
27
|
+
await writeInstructionFiles({
|
|
28
|
+
dir,
|
|
29
|
+
marker: LUMINE_AGENT_INSTRUCTIONS_MARKER,
|
|
30
|
+
content: LUMINE_AGENT_INSTRUCTIONS,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function writeReferenceInstructions({ dir }) {
|
|
35
|
+
await writeInstructionFiles({
|
|
36
|
+
dir,
|
|
37
|
+
marker: LUMINE_REFERENCE_INSTRUCTIONS_MARKER,
|
|
38
|
+
content: LUMINE_REFERENCE_INSTRUCTIONS,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Write AGENTS.md/CLAUDE.md, but never clobber a file the user customized:
|
|
43
|
+
// only (re)write when the file is absent or still carries our marker.
|
|
44
|
+
export async function writeInstructionFiles({ dir, marker, content }) {
|
|
45
|
+
for (const fileName of AGENT_INSTRUCTION_FILES) {
|
|
46
|
+
const filePath = path.join(dir, fileName);
|
|
47
|
+
try {
|
|
48
|
+
const existing = await fs.readFile(filePath, "utf8");
|
|
49
|
+
if (!existing.includes(marker)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code !== "ENOENT") throw error;
|
|
54
|
+
}
|
|
55
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function writeSdkReference({ dir }) {
|
|
60
|
+
const filePath = path.join(dir, SDK_REFERENCE_FILE);
|
|
61
|
+
try {
|
|
62
|
+
const existing = await fs.readFile(filePath, "utf8");
|
|
63
|
+
if (!existing.includes(LUMINE_SDK_REFERENCE_MARKER)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code !== "ENOENT") throw error;
|
|
68
|
+
}
|
|
69
|
+
await fs.writeFile(filePath, await loadSdkReference(), "utf8");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function loadSdkReference() {
|
|
73
|
+
try {
|
|
74
|
+
const rawReference = await fs.readFile(BUNDLED_SDK_REFERENCE_URL, "utf8");
|
|
75
|
+
const reference = rawReference.trim();
|
|
76
|
+
if (reference) return `${LUMINE_SDK_REFERENCE_MARKER}\n${reference}\n`;
|
|
77
|
+
} catch {
|
|
78
|
+
// Fall through to the compact reference so pulled workspaces still guide agents.
|
|
79
|
+
}
|
|
80
|
+
return SDK_REFERENCE_FALLBACK;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function collectProjectFiles(dir) {
|
|
84
|
+
const root = path.resolve(dir);
|
|
85
|
+
const files = [];
|
|
86
|
+
await collectProjectFilesFromDir({ root, dir: root, files });
|
|
87
|
+
if (!files.some((file) => isIndexHtmlPath(file.path))) {
|
|
88
|
+
throw new Error("Project files must include /index.html or /index.htm.");
|
|
89
|
+
}
|
|
90
|
+
assertProjectFilesAvoidNativeFormSubmission(files);
|
|
91
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Tolerant collector for read-only scans (asset-reference detection): skips
|
|
95
|
+
// unreadable/binary files instead of throwing and requires no entry file.
|
|
96
|
+
export async function collectWorkspaceTextFilesLenient(dir) {
|
|
97
|
+
const root = path.resolve(dir);
|
|
98
|
+
const files = [];
|
|
99
|
+
try {
|
|
100
|
+
await collectProjectFilesFromDir({ root, dir: root, files, lenient: true });
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
return files;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function collectProjectFilesFromDir({ root, dir, files, lenient = false }) {
|
|
108
|
+
let entries = [];
|
|
109
|
+
try {
|
|
110
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error.code === "ENOENT") {
|
|
113
|
+
throw new Error(`Project directory does not exist: ${dir}`);
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
if (entry.isDirectory() && EXCLUDED_UPLOAD_DIRS.has(entry.name)) continue;
|
|
120
|
+
if (entry.isFile() && EXCLUDED_UPLOAD_FILES.has(entry.name)) continue;
|
|
121
|
+
const fullPath = path.join(dir, entry.name);
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
// A nested Lumine checkout (its own .twinkle metadata) is another
|
|
124
|
+
// project that happens to sit here — never upload it as project files.
|
|
125
|
+
if (await isNestedLumineCheckout(fullPath)) {
|
|
126
|
+
console.error(
|
|
127
|
+
`lumine: skipping nested Lumine checkout ${path.relative(root, fullPath)}/ (not part of this project)`,
|
|
128
|
+
);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
await collectProjectFilesFromDir({ root, dir: fullPath, files, lenient });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!entry.isFile()) continue;
|
|
135
|
+
const buffer = await fs.readFile(fullPath);
|
|
136
|
+
const encodingIssue = detectProjectFileEncodingIssue(buffer);
|
|
137
|
+
if (encodingIssue) {
|
|
138
|
+
if (lenient) continue;
|
|
139
|
+
const relativePath = localFilePathToProjectPath({
|
|
140
|
+
root,
|
|
141
|
+
filePath: fullPath,
|
|
142
|
+
});
|
|
143
|
+
if (encodingIssue === "utf16") {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Cannot save ${relativePath}: it is UTF-16 encoded. Twinkle project files must be UTF-8 text — re-save the file as UTF-8 and retry.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Cannot save binary file ${relativePath}. Twinkle project files must be text files. ` +
|
|
150
|
+
`Upload media as a build asset instead: move the file out of this workspace, run ` +
|
|
151
|
+
"`lumine assets upload <path-to-file>`, and reference the printed URL from your code.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
files.push({
|
|
155
|
+
path: localFilePathToProjectPath({ root, filePath: fullPath }),
|
|
156
|
+
content: buffer.toString("utf8"),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// UTF-16 BOMs get their own error (the file is text, just mis-encoded); NUL
|
|
162
|
+
// bytes or content that does not round-trip through UTF-8 is binary. The
|
|
163
|
+
// round-trip check matters: the server does no encoding validation, so
|
|
164
|
+
// NUL-free binary sent as JSON would be silently corrupted into U+FFFD
|
|
165
|
+
// replacement characters — this is the only gate.
|
|
166
|
+
export function detectProjectFileEncodingIssue(buffer) {
|
|
167
|
+
if (buffer.length >= 2) {
|
|
168
|
+
const [b0, b1] = buffer;
|
|
169
|
+
if ((b0 === 0xff && b1 === 0xfe) || (b0 === 0xfe && b1 === 0xff)) {
|
|
170
|
+
return "utf16";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (buffer.includes(0)) return "binary";
|
|
174
|
+
if (!Buffer.from(buffer.toString("utf8"), "utf8").equals(buffer)) {
|
|
175
|
+
return "binary";
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function isNestedLumineCheckout(dir) {
|
|
181
|
+
try {
|
|
182
|
+
await fs.access(path.join(dir, PROJECT_METADATA_DIR, PROJECT_METADATA_FILE));
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function localFilePathToProjectPath({ root, filePath }) {
|
|
190
|
+
const relative = path.relative(root, filePath).replace(/\\/g, "/");
|
|
191
|
+
if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) {
|
|
192
|
+
throw new Error(`Unsafe local project file path: ${filePath}`);
|
|
193
|
+
}
|
|
194
|
+
return `/${relative}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function isIndexHtmlPath(projectPath) {
|
|
198
|
+
const normalized = String(projectPath || "")
|
|
199
|
+
.trim()
|
|
200
|
+
.toLowerCase();
|
|
201
|
+
return normalized === "/index.html" || normalized === "/index.htm";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function isNativeFormMarkupProjectPath(projectPath) {
|
|
205
|
+
return /\.(?:html?|jsx?|tsx?|mjs|cjs)$/i.test(
|
|
206
|
+
String(projectPath || "").trim(),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function getLineColumnForSourceIndex(content, sourceIndex) {
|
|
211
|
+
const before = String(content || "").slice(0, Math.max(0, sourceIndex));
|
|
212
|
+
const lines = before.split("\n");
|
|
213
|
+
return {
|
|
214
|
+
line: lines.length,
|
|
215
|
+
column: lines[lines.length - 1].length + 1,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function formatProjectFileLocation({ filePath, line, column }) {
|
|
220
|
+
return `${filePath}:${line}:${column}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function assertProjectFilesAvoidNativeFormSubmission(files) {
|
|
224
|
+
for (const file of files) {
|
|
225
|
+
const content = String(file.content || "");
|
|
226
|
+
if (isNativeFormMarkupProjectPath(file.path)) {
|
|
227
|
+
const formMatch = content.match(/<form\b[^>]*>/i);
|
|
228
|
+
if (formMatch) {
|
|
229
|
+
const { line, column } = getLineColumnForSourceIndex(
|
|
230
|
+
content,
|
|
231
|
+
formMatch.index || 0,
|
|
232
|
+
);
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Cannot save native form markup at ${formatProjectFileLocation({
|
|
235
|
+
filePath: file.path,
|
|
236
|
+
line,
|
|
237
|
+
column,
|
|
238
|
+
})}. Twinkle Build apps run in sandboxed iframes without form-submit permission. Replace <form> with JavaScript-handled inputs and buttons.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const requestSubmitMatch = content.match(/\brequestSubmit\s*\(/);
|
|
244
|
+
if (requestSubmitMatch) {
|
|
245
|
+
const { line, column } = getLineColumnForSourceIndex(
|
|
246
|
+
content,
|
|
247
|
+
requestSubmitMatch.index || 0,
|
|
248
|
+
);
|
|
249
|
+
throw new Error(
|
|
250
|
+
`Cannot save native form submission at ${formatProjectFileLocation({
|
|
251
|
+
filePath: file.path,
|
|
252
|
+
line,
|
|
253
|
+
column,
|
|
254
|
+
})}. requestSubmit() is blocked by the Build iframe sandbox; use a JavaScript click handler instead.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Mirrors the server's effective-line counting: long physical lines count as
|
|
261
|
+
// one extra line per PROJECT_EFFECTIVE_LINE_MAX_COLUMNS characters.
|
|
262
|
+
export function countEffectiveLines(value) {
|
|
263
|
+
const normalized = String(value || "").replace(/\r\n?/g, "\n");
|
|
264
|
+
if (!normalized) return 0;
|
|
265
|
+
return normalized
|
|
266
|
+
.split("\n")
|
|
267
|
+
.reduce(
|
|
268
|
+
(total, line) =>
|
|
269
|
+
total +
|
|
270
|
+
Math.max(1, Math.ceil(line.length / PROJECT_EFFECTIVE_LINE_MAX_COLUMNS)),
|
|
271
|
+
0,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Local mirror of the server's project limits so violations fail fast with
|
|
276
|
+
// the full list (the server stops at the first). File count and per-file
|
|
277
|
+
// effective lines are hard server constants -> errors; the total-byte limit
|
|
278
|
+
// is env-overridable server-side -> warning only, the server stays the gate.
|
|
279
|
+
export function collectProjectLimitFindings(files) {
|
|
280
|
+
const errors = [];
|
|
281
|
+
const warnings = [];
|
|
282
|
+
if (files.length > PROJECT_MAX_FILES) {
|
|
283
|
+
errors.push(
|
|
284
|
+
`Project exceeds the ${PROJECT_MAX_FILES}-file limit (${files.length} files). Split or consolidate files before saving.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
for (const file of files) {
|
|
288
|
+
const effectiveLineCount = countEffectiveLines(file.content);
|
|
289
|
+
if (effectiveLineCount > PROJECT_MAX_EFFECTIVE_FILE_LINES) {
|
|
290
|
+
errors.push(
|
|
291
|
+
`File "${file.path}" exceeds the per-file limit of ${PROJECT_MAX_EFFECTIVE_FILE_LINES} effective lines ` +
|
|
292
|
+
`(${effectiveLineCount} effective lines). Split it into multiple files and folders instead. ` +
|
|
293
|
+
`Long physical lines count as additional effective lines every ${PROJECT_EFFECTIVE_LINE_MAX_COLUMNS} characters.`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const totalBytes = files.reduce(
|
|
298
|
+
(total, file) => total + Buffer.byteLength(String(file.content || ""), "utf8"),
|
|
299
|
+
0,
|
|
300
|
+
);
|
|
301
|
+
if (totalBytes > PROJECT_MAX_TOTAL_BYTES_DEFAULT) {
|
|
302
|
+
warnings.push(
|
|
303
|
+
`Project files total ${formatBytes(totalBytes)}, above the default ${Math.floor(
|
|
304
|
+
PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
|
|
305
|
+
)} KB project limit — the server may reject this save.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return { errors, warnings };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function assertProjectFilesWithinLimits(files) {
|
|
312
|
+
const { errors, warnings } = collectProjectLimitFindings(files);
|
|
313
|
+
for (const warning of warnings) {
|
|
314
|
+
console.error(`lumine: warning — ${warning}`);
|
|
315
|
+
}
|
|
316
|
+
if (errors.length > 0) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
errors.length === 1
|
|
319
|
+
? errors[0]
|
|
320
|
+
: `Project limit violations:\n - ${errors.join("\n - ")}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function writeProjectFiles({ dir, files }) {
|
|
326
|
+
await fs.mkdir(dir, { recursive: true });
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
const filePath = resolveLocalProjectFilePath({
|
|
329
|
+
rootDir: dir,
|
|
330
|
+
projectPath: file.path,
|
|
331
|
+
});
|
|
332
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
333
|
+
await fs.writeFile(filePath, String(file.content || ""), "utf8");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// After update-from-main, files that main deleted must also leave the local
|
|
338
|
+
// workspace — otherwise the next save would resurrect them on the branch.
|
|
339
|
+
export async function removeLocalProjectFilesNotIn({ dir, files }) {
|
|
340
|
+
const keep = new Set(files.map((file) => String(file.path)));
|
|
341
|
+
const localFiles = await collectProjectFiles(dir);
|
|
342
|
+
for (const file of localFiles) {
|
|
343
|
+
if (keep.has(file.path)) continue;
|
|
344
|
+
await fs.unlink(
|
|
345
|
+
resolveLocalProjectFilePath({ rootDir: dir, projectPath: file.path }),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function writeProjectMetadata({
|
|
351
|
+
dir,
|
|
352
|
+
options,
|
|
353
|
+
build,
|
|
354
|
+
manifest,
|
|
355
|
+
pulledAt,
|
|
356
|
+
lastSavedAt,
|
|
357
|
+
}) {
|
|
358
|
+
const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
|
|
359
|
+
await fs.mkdir(metadataDir, { recursive: true });
|
|
360
|
+
await fs.writeFile(
|
|
361
|
+
path.join(metadataDir, PROJECT_METADATA_FILE),
|
|
362
|
+
JSON.stringify(
|
|
363
|
+
{
|
|
364
|
+
schemaVersion: 1,
|
|
365
|
+
buildId: Number(build?.id || 0) || null,
|
|
366
|
+
build: build
|
|
367
|
+
? {
|
|
368
|
+
id: Number(build.id || 0) || null,
|
|
369
|
+
title: build.title || "",
|
|
370
|
+
role: build.role || "",
|
|
371
|
+
ownerUsername: build.ownerUsername || null,
|
|
372
|
+
contributionStatus: build.contributionStatus || "none",
|
|
373
|
+
contributionRootBuildId:
|
|
374
|
+
Number(build.contributionRootBuildId || 0) || null,
|
|
375
|
+
contributionContributorId:
|
|
376
|
+
Number(build.contributionContributorId || 0) || null,
|
|
377
|
+
contributionBranchNumber:
|
|
378
|
+
Number(build.contributionBranchNumber || 0) || null,
|
|
379
|
+
canWrite:
|
|
380
|
+
build.canWrite !== undefined
|
|
381
|
+
? Boolean(build.canWrite)
|
|
382
|
+
: build.role === "owner",
|
|
383
|
+
canPublish:
|
|
384
|
+
build.canPublish !== undefined
|
|
385
|
+
? Boolean(build.canPublish)
|
|
386
|
+
: build.role === "owner",
|
|
387
|
+
}
|
|
388
|
+
: null,
|
|
389
|
+
apiUrl: options.apiUrl,
|
|
390
|
+
siteUrl: options.siteUrl,
|
|
391
|
+
lumineCli: serializeLumineCliMetadata(options),
|
|
392
|
+
manifest,
|
|
393
|
+
pulledAt,
|
|
394
|
+
lastSavedAt,
|
|
395
|
+
},
|
|
396
|
+
null,
|
|
397
|
+
2,
|
|
398
|
+
),
|
|
399
|
+
"utf8",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function writeReferenceMetadata({
|
|
404
|
+
dir,
|
|
405
|
+
options,
|
|
406
|
+
build,
|
|
407
|
+
manifest,
|
|
408
|
+
reference,
|
|
409
|
+
pulledAt,
|
|
410
|
+
}) {
|
|
411
|
+
const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
|
|
412
|
+
await fs.mkdir(metadataDir, { recursive: true });
|
|
413
|
+
const sourceBuildId =
|
|
414
|
+
Number(reference?.sourceBuildId || 0) || Number(build?.id || 0) || null;
|
|
415
|
+
await fs.writeFile(
|
|
416
|
+
path.join(metadataDir, PROJECT_METADATA_FILE),
|
|
417
|
+
JSON.stringify(
|
|
418
|
+
{
|
|
419
|
+
schemaVersion: 1,
|
|
420
|
+
buildId: sourceBuildId,
|
|
421
|
+
readOnly: true,
|
|
422
|
+
reference: {
|
|
423
|
+
readOnly: true,
|
|
424
|
+
forkable: true,
|
|
425
|
+
sourceBuildId,
|
|
426
|
+
sourceAppUrl: sourceBuildId ? `${options.siteUrl}/app/${sourceBuildId}` : null,
|
|
427
|
+
},
|
|
428
|
+
build: {
|
|
429
|
+
id: sourceBuildId,
|
|
430
|
+
title: build?.title || (sourceBuildId ? `Build ${sourceBuildId}` : ""),
|
|
431
|
+
role: "reference",
|
|
432
|
+
ownerUsername: build?.ownerUsername || null,
|
|
433
|
+
collaborationMode: build?.collaborationMode || "open_source",
|
|
434
|
+
canWrite: false,
|
|
435
|
+
canPublish: false,
|
|
436
|
+
},
|
|
437
|
+
apiUrl: options.apiUrl,
|
|
438
|
+
siteUrl: options.siteUrl,
|
|
439
|
+
lumineCli: serializeLumineCliMetadata(options),
|
|
440
|
+
manifest,
|
|
441
|
+
pulledAt,
|
|
442
|
+
},
|
|
443
|
+
null,
|
|
444
|
+
2,
|
|
445
|
+
),
|
|
446
|
+
"utf8",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Metadata for a read-only `pull --main` checkout: canWrite is forced false
|
|
451
|
+
// (the files endpoint reports token scope, not role) and mainCheckout marks it
|
|
452
|
+
// so save can explain where edits belong.
|
|
453
|
+
export async function writeMainCheckoutMetadata({
|
|
454
|
+
dir,
|
|
455
|
+
options,
|
|
456
|
+
build,
|
|
457
|
+
manifest,
|
|
458
|
+
pulledAt,
|
|
459
|
+
}) {
|
|
460
|
+
const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
|
|
461
|
+
await fs.mkdir(metadataDir, { recursive: true });
|
|
462
|
+
const rootBuildId = Number(build?.id || 0) || null;
|
|
463
|
+
await fs.writeFile(
|
|
464
|
+
path.join(metadataDir, PROJECT_METADATA_FILE),
|
|
465
|
+
JSON.stringify(
|
|
466
|
+
{
|
|
467
|
+
schemaVersion: 1,
|
|
468
|
+
buildId: rootBuildId,
|
|
469
|
+
readOnly: true,
|
|
470
|
+
mainCheckout: true,
|
|
471
|
+
build: {
|
|
472
|
+
id: rootBuildId,
|
|
473
|
+
title: build?.title || (rootBuildId ? `Build ${rootBuildId}` : ""),
|
|
474
|
+
role: build?.role || "collaborator",
|
|
475
|
+
ownerUsername: build?.ownerUsername || null,
|
|
476
|
+
contributionStatus: "none",
|
|
477
|
+
contributionRootBuildId: null,
|
|
478
|
+
canWrite: false,
|
|
479
|
+
canPublish: false,
|
|
480
|
+
},
|
|
481
|
+
apiUrl: options.apiUrl,
|
|
482
|
+
siteUrl: options.siteUrl,
|
|
483
|
+
lumineCli: serializeLumineCliMetadata(options),
|
|
484
|
+
manifest,
|
|
485
|
+
pulledAt,
|
|
486
|
+
},
|
|
487
|
+
null,
|
|
488
|
+
2,
|
|
489
|
+
),
|
|
490
|
+
"utf8",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function findLocalProjectMetadata(startDir) {
|
|
495
|
+
let current = path.resolve(startDir || process.cwd());
|
|
496
|
+
while (true) {
|
|
497
|
+
const metadataPath = path.join(
|
|
498
|
+
current,
|
|
499
|
+
PROJECT_METADATA_DIR,
|
|
500
|
+
PROJECT_METADATA_FILE,
|
|
501
|
+
);
|
|
502
|
+
try {
|
|
503
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
504
|
+
return { rootDir: current, metadata, metadataPath };
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (error.code !== "ENOENT") throw error;
|
|
507
|
+
}
|
|
508
|
+
const parent = path.dirname(current);
|
|
509
|
+
if (parent === current) return null;
|
|
510
|
+
current = parent;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function resolveProjectDirForSave({ options, localProject }) {
|
|
515
|
+
if (options.dir) return path.resolve(options.dir);
|
|
516
|
+
if (localProject?.rootDir) return localProject.rootDir;
|
|
517
|
+
return process.cwd();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function assertLocalProjectCanBeSaved(localProject) {
|
|
521
|
+
const metadata = localProject?.metadata;
|
|
522
|
+
if (!metadata) return;
|
|
523
|
+
if (metadata.mainCheckout === true) {
|
|
524
|
+
const rootBuildId =
|
|
525
|
+
Number(metadata.buildId || 0) || Number(metadata.build?.id || 0) || 0;
|
|
526
|
+
throw new Error(
|
|
527
|
+
`This is a read-only checkout of main${rootBuildId ? ` for Build ${rootBuildId}` : ""}. Make edits in your branch workspace (\`lumine pull${rootBuildId ? ` ${rootBuildId}` : ""}\`), and run \`lumine update-from-main\` there to bring main's changes into it.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (isReadOnlyReferenceMetadata(metadata)) {
|
|
531
|
+
const sourceBuildId =
|
|
532
|
+
Number(metadata.reference?.sourceBuildId || 0) ||
|
|
533
|
+
Number(metadata.buildId || 0) ||
|
|
534
|
+
Number(metadata.build?.id || 0) ||
|
|
535
|
+
0;
|
|
536
|
+
throw new Error(
|
|
537
|
+
`This is a read-only Lumine reference${sourceBuildId ? ` for Build ${sourceBuildId}` : ""}. Run \`lumine fork${sourceBuildId ? ` ${sourceBuildId}` : ""}\` to create an editable workspace.`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
if (metadata.build?.canWrite === false) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
"This Lumine checkout is read-only for the current CLI login. Pull or diff it for review; project-owner branch edits must go through merge or replace-main.",
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function isReadOnlyReferenceMetadata(metadata) {
|
|
548
|
+
return (
|
|
549
|
+
metadata?.readOnly === true ||
|
|
550
|
+
metadata?.reference?.readOnly === true ||
|
|
551
|
+
metadata?.build?.role === "reference"
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function resolveLocalProjectFilePath({ rootDir, projectPath }) {
|
|
556
|
+
const relativePath = projectPathToRelativePath(projectPath);
|
|
557
|
+
const root = path.resolve(rootDir);
|
|
558
|
+
const filePath = path.resolve(root, relativePath);
|
|
559
|
+
if (filePath !== root && filePath.startsWith(`${root}${path.sep}`)) {
|
|
560
|
+
return filePath;
|
|
561
|
+
}
|
|
562
|
+
throw new Error(`Unsafe project file path: ${projectPath}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function projectPathToRelativePath(projectPath) {
|
|
566
|
+
const segments = String(projectPath || "")
|
|
567
|
+
.replace(/\\/g, "/")
|
|
568
|
+
.split("/")
|
|
569
|
+
.filter(Boolean);
|
|
570
|
+
if (
|
|
571
|
+
segments.length === 0 ||
|
|
572
|
+
segments.some((segment) => segment === "." || segment === "..")
|
|
573
|
+
) {
|
|
574
|
+
throw new Error(`Unsafe project file path: ${projectPath}`);
|
|
575
|
+
}
|
|
576
|
+
return path.join(...segments);
|
|
577
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage5/lumine",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Command line tools for launching Lumine builds on Twinkle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
+
"lib",
|
|
11
12
|
"sdk",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
14
18
|
"publishConfig": {
|
|
15
19
|
"access": "public"
|
|
16
20
|
},
|