@loworbitstudio/visor 0.10.2 → 1.2.0
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/README.md +36 -0
- package/dist/CHANGELOG.json +73 -1
- package/dist/index.js +1128 -24
- package/dist/registry.json +481 -21
- package/dist/visor-manifest.json +998 -16
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
5
|
-
import { dirname as
|
|
6
|
-
import { fileURLToPath as
|
|
4
|
+
import { readFileSync as readFileSync26 } from "fs";
|
|
5
|
+
import { dirname as dirname10, join as join24 } from "path";
|
|
6
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
7
7
|
import { Command as Command2 } from "commander";
|
|
8
8
|
|
|
9
9
|
// src/commands/check.ts
|
|
@@ -3843,7 +3843,7 @@ import {
|
|
|
3843
3843
|
unlinkSync as unlinkSync2,
|
|
3844
3844
|
copyFileSync
|
|
3845
3845
|
} from "fs";
|
|
3846
|
-
import { join as join15, basename as basename3, resolve as resolve11, sep } from "path";
|
|
3846
|
+
import { join as join15, basename as basename3, resolve as resolve11, sep, relative as relative2 } from "path";
|
|
3847
3847
|
import { parse as parseYaml2 } from "yaml";
|
|
3848
3848
|
import { generateThemeData as generateThemeData4, validateFontCoverage, formatFontCoverageError } from "@loworbitstudio/visor-theme-engine";
|
|
3849
3849
|
import { docsAdapter as docsAdapter3 } from "@loworbitstudio/visor-theme-engine/adapters";
|
|
@@ -3939,6 +3939,32 @@ function resolveCustomSources(repoRoot, mainRepoRoot, warn) {
|
|
|
3939
3939
|
}
|
|
3940
3940
|
return { files: [...merged.values()], deprecationWarnings };
|
|
3941
3941
|
}
|
|
3942
|
+
function emitFailureSummary({
|
|
3943
|
+
failures,
|
|
3944
|
+
succeeded,
|
|
3945
|
+
options,
|
|
3946
|
+
cwd
|
|
3947
|
+
}) {
|
|
3948
|
+
if (options.json) {
|
|
3949
|
+
console.log(
|
|
3950
|
+
JSON.stringify({
|
|
3951
|
+
success: false,
|
|
3952
|
+
themes: succeeded,
|
|
3953
|
+
failures: failures.map((f) => ({ filePath: f.filePath, error: f.error }))
|
|
3954
|
+
})
|
|
3955
|
+
);
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
const lines2 = [
|
|
3959
|
+
`Theme sync: ${succeeded} succeeded, ${failures.length} failed.`,
|
|
3960
|
+
"",
|
|
3961
|
+
"Failed:",
|
|
3962
|
+
...failures.map((f) => ` ${relative2(cwd, f.filePath)} \u2014 ${f.error}`),
|
|
3963
|
+
"",
|
|
3964
|
+
"Re-run after fixing the failed themes."
|
|
3965
|
+
];
|
|
3966
|
+
logger.error(lines2.join("\n"));
|
|
3967
|
+
}
|
|
3942
3968
|
function reportBrokenSymlink(err, options) {
|
|
3943
3969
|
const msg = `Broken symlink in theme source: ${err.path} \u2192 ${err.target}`;
|
|
3944
3970
|
if (options.json) {
|
|
@@ -4220,20 +4246,23 @@ function themeSyncCommand(cwd, options) {
|
|
|
4220
4246
|
for (const w of discoveryWarnings) logger.warn(w);
|
|
4221
4247
|
}
|
|
4222
4248
|
const manifest = [];
|
|
4223
|
-
const
|
|
4249
|
+
const failures = [];
|
|
4224
4250
|
const processFile = (filePath, isCustom, slugOverride) => {
|
|
4225
4251
|
let yamlContent;
|
|
4226
4252
|
try {
|
|
4227
4253
|
yamlContent = readFileSync16(filePath, "utf-8");
|
|
4228
4254
|
} catch {
|
|
4229
|
-
|
|
4255
|
+
failures.push({ filePath, error: `Could not read: ${filePath}` });
|
|
4230
4256
|
return;
|
|
4231
4257
|
}
|
|
4232
4258
|
let data;
|
|
4233
4259
|
try {
|
|
4234
4260
|
data = generateThemeData4(yamlContent);
|
|
4235
4261
|
} catch (err) {
|
|
4236
|
-
|
|
4262
|
+
failures.push({
|
|
4263
|
+
filePath,
|
|
4264
|
+
error: `Failed to parse ${basename3(filePath)}: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
4265
|
+
});
|
|
4237
4266
|
return;
|
|
4238
4267
|
}
|
|
4239
4268
|
const slug2 = slugOverride ?? toSlug(data.config.name);
|
|
@@ -4244,7 +4273,10 @@ function themeSyncCommand(cwd, options) {
|
|
|
4244
4273
|
const coverage = validateFontCoverage(css);
|
|
4245
4274
|
if (coverage.errors.length > 0) {
|
|
4246
4275
|
for (const e of coverage.errors) {
|
|
4247
|
-
|
|
4276
|
+
failures.push({
|
|
4277
|
+
filePath,
|
|
4278
|
+
error: formatFontCoverageError(basename3(filePath), e.declaredAt, e.family)
|
|
4279
|
+
});
|
|
4248
4280
|
}
|
|
4249
4281
|
return;
|
|
4250
4282
|
}
|
|
@@ -4256,12 +4288,8 @@ function themeSyncCommand(cwd, options) {
|
|
|
4256
4288
|
const isNested = c.origin !== "legacy";
|
|
4257
4289
|
processFile(c.filePath, true, isNested ? c.slug : void 0);
|
|
4258
4290
|
}
|
|
4259
|
-
if (
|
|
4260
|
-
|
|
4261
|
-
console.log(JSON.stringify({ success: false, errors }));
|
|
4262
|
-
} else {
|
|
4263
|
-
errors.forEach((e) => logger.error(e));
|
|
4264
|
-
}
|
|
4291
|
+
if (manifest.length === 0) {
|
|
4292
|
+
emitFailureSummary({ failures, succeeded: 0, options, cwd });
|
|
4265
4293
|
process.exit(1);
|
|
4266
4294
|
return;
|
|
4267
4295
|
}
|
|
@@ -4297,6 +4325,7 @@ function themeSyncCommand(cwd, options) {
|
|
|
4297
4325
|
const newPublicYamlSet = new Set(manifest.map((e) => `${e.yamlFilename}.visor.yaml`));
|
|
4298
4326
|
const stalePublicYamls = existingPublicYamls.filter((f) => !newPublicYamlSet.has(f));
|
|
4299
4327
|
if (options.dryRun) {
|
|
4328
|
+
const dryRunHasFailures = failures.length > 0;
|
|
4300
4329
|
const changes = {
|
|
4301
4330
|
themesDiscovered: manifest.map((e) => ({ slug: e.slug, group: e.group, isCustom: e.isCustom })),
|
|
4302
4331
|
cssFilesGenerated: allSlugs.map((s) => `packages/docs/app/${s}-theme.css`),
|
|
@@ -4309,13 +4338,25 @@ function themeSyncCommand(cwd, options) {
|
|
|
4309
4338
|
publicYamlsDeleted: stalePublicYamls.map((f) => `packages/docs/public/themes/${f}`)
|
|
4310
4339
|
};
|
|
4311
4340
|
if (options.json) {
|
|
4312
|
-
console.log(JSON.stringify({
|
|
4341
|
+
console.log(JSON.stringify({
|
|
4342
|
+
success: !dryRunHasFailures,
|
|
4343
|
+
dryRun: true,
|
|
4344
|
+
changes,
|
|
4345
|
+
...dryRunHasFailures ? { failures: failures.map((f) => ({ filePath: f.filePath, error: f.error })) } : {}
|
|
4346
|
+
}));
|
|
4313
4347
|
} else {
|
|
4314
4348
|
logger.info("Dry run \u2014 no files written");
|
|
4315
4349
|
logger.item(`Themes discovered: ${manifest.length} (${stockManifest.length} stock, ${customManifest.length} custom)`);
|
|
4316
4350
|
manifest.forEach((e) => logger.item(` ${e.slug} \u2014 group: ${e.group}`));
|
|
4317
4351
|
if (staleCssFiles.length > 0) logger.item(`CSS files to delete: ${staleCssFiles.join(", ")}`);
|
|
4318
4352
|
if (stalePublicYamls.length > 0) logger.item(`Public YAMLs to delete: ${stalePublicYamls.join(", ")}`);
|
|
4353
|
+
if (dryRunHasFailures) {
|
|
4354
|
+
emitFailureSummary({ failures, succeeded: manifest.length, options, cwd });
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
if (dryRunHasFailures) {
|
|
4358
|
+
process.exit(1);
|
|
4359
|
+
return;
|
|
4319
4360
|
}
|
|
4320
4361
|
return;
|
|
4321
4362
|
}
|
|
@@ -4353,20 +4394,28 @@ function themeSyncCommand(cwd, options) {
|
|
|
4353
4394
|
process.exit(2);
|
|
4354
4395
|
return;
|
|
4355
4396
|
}
|
|
4397
|
+
const hasFailures = failures.length > 0;
|
|
4356
4398
|
if (options.json) {
|
|
4357
4399
|
const warnings = [...deprecationWarnings, ...discoveryWarnings];
|
|
4358
4400
|
console.log(JSON.stringify({
|
|
4359
|
-
success:
|
|
4401
|
+
success: !hasFailures,
|
|
4360
4402
|
themes: manifest.length,
|
|
4361
4403
|
stock: stockManifest.length,
|
|
4362
4404
|
custom: customManifest.length,
|
|
4363
4405
|
staleCssDeleted: staleCssFiles.length,
|
|
4364
4406
|
staleYamlsDeleted: stalePublicYamls.length,
|
|
4365
4407
|
slugs: allSlugs,
|
|
4366
|
-
...warnings.length > 0 ? { warnings } : {}
|
|
4408
|
+
...warnings.length > 0 ? { warnings } : {},
|
|
4409
|
+
...hasFailures ? {
|
|
4410
|
+
failures: failures.map((f) => ({ filePath: f.filePath, error: f.error }))
|
|
4411
|
+
} : {}
|
|
4367
4412
|
}));
|
|
4368
4413
|
} else {
|
|
4369
|
-
|
|
4414
|
+
if (hasFailures) {
|
|
4415
|
+
logger.info(`Theme sync partial \u2014 ${manifest.length} themes registered`);
|
|
4416
|
+
} else {
|
|
4417
|
+
logger.success(`Theme sync complete \u2014 ${manifest.length} themes registered`);
|
|
4418
|
+
}
|
|
4370
4419
|
logger.item(`Stock: ${stockManifest.map((e) => e.slug).join(", ")}`);
|
|
4371
4420
|
if (customManifest.length > 0) {
|
|
4372
4421
|
logger.item(`Custom: ${customManifest.map((e) => e.slug).join(", ")}`);
|
|
@@ -4374,6 +4423,13 @@ function themeSyncCommand(cwd, options) {
|
|
|
4374
4423
|
if (staleCssFiles.length > 0) {
|
|
4375
4424
|
logger.item(`Removed stale CSS: ${staleCssFiles.join(", ")}`);
|
|
4376
4425
|
}
|
|
4426
|
+
if (hasFailures) {
|
|
4427
|
+
emitFailureSummary({ failures, succeeded: manifest.length, options, cwd });
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
if (hasFailures) {
|
|
4431
|
+
process.exit(1);
|
|
4432
|
+
return;
|
|
4377
4433
|
}
|
|
4378
4434
|
}
|
|
4379
4435
|
|
|
@@ -5486,7 +5542,7 @@ Visor Tokens (${categoryLabel}) \u2014 ${tokens2.length} tokens
|
|
|
5486
5542
|
|
|
5487
5543
|
// src/commands/migrate-token-substitution.ts
|
|
5488
5544
|
import { readFileSync as readFileSync21, writeFileSync as writeFileSync11, readdirSync as readdirSync11, statSync as statSync8, existsSync as existsSync18 } from "fs";
|
|
5489
|
-
import { resolve as resolve13, join as join19, relative as
|
|
5545
|
+
import { resolve as resolve13, join as join19, relative as relative4 } from "path";
|
|
5490
5546
|
import { parse as parseYaml3 } from "yaml";
|
|
5491
5547
|
import pc4 from "picocolors";
|
|
5492
5548
|
var V7_ENTR_SUBSTITUTION_MAP = {
|
|
@@ -5679,7 +5735,7 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
|
|
|
5679
5735
|
process.exit(0);
|
|
5680
5736
|
return;
|
|
5681
5737
|
}
|
|
5682
|
-
const relTarget =
|
|
5738
|
+
const relTarget = relative4(cwd, targetPath) || ".";
|
|
5683
5739
|
if (dryRun) {
|
|
5684
5740
|
logger.heading(`visor migrate token-substitution \u2014 dry run`);
|
|
5685
5741
|
logger.blank();
|
|
@@ -5690,7 +5746,7 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
|
|
|
5690
5746
|
logger.heading(`Proposed changes (${result.filesChanged} file(s), ${result.totalSubstitutions} substitution(s)):`);
|
|
5691
5747
|
logger.blank();
|
|
5692
5748
|
for (const f of result.files) {
|
|
5693
|
-
const relFile =
|
|
5749
|
+
const relFile = relative4(cwd, f.file);
|
|
5694
5750
|
logger.info(pc4.bold(` ${relFile}`));
|
|
5695
5751
|
for (const sub of f.substitutions) {
|
|
5696
5752
|
logger.info(
|
|
@@ -5711,7 +5767,7 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
|
|
|
5711
5767
|
logger.info(` Scanned: ${result.filesScanned} file(s)`);
|
|
5712
5768
|
logger.blank();
|
|
5713
5769
|
for (const f of result.files) {
|
|
5714
|
-
const relFile =
|
|
5770
|
+
const relFile = relative4(cwd, f.file);
|
|
5715
5771
|
logger.success(` ${relFile} (${f.substitutions.length} substitution(s))`);
|
|
5716
5772
|
}
|
|
5717
5773
|
logger.blank();
|
|
@@ -5722,10 +5778,1044 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
|
|
|
5722
5778
|
process.exit(0);
|
|
5723
5779
|
}
|
|
5724
5780
|
|
|
5781
|
+
// src/commands/sandbox/init.ts
|
|
5782
|
+
import { existsSync as existsSync21, mkdirSync as mkdirSync8, readFileSync as readFileSync23, rmSync as rmSync2 } from "fs";
|
|
5783
|
+
import { isAbsolute as isAbsolute2, join as join21, resolve as resolve15, dirname as dirname9 } from "path";
|
|
5784
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
5785
|
+
import * as childProcess2 from "child_process";
|
|
5786
|
+
|
|
5787
|
+
// src/commands/sandbox/parse-handoff.ts
|
|
5788
|
+
import { readFileSync as readFileSync22, existsSync as existsSync19 } from "fs";
|
|
5789
|
+
import { dirname as dirname8, resolve as resolve14, isAbsolute } from "path";
|
|
5790
|
+
function parseHandoff(handoffPath) {
|
|
5791
|
+
const text = readFileSync22(handoffPath, "utf-8");
|
|
5792
|
+
const lines2 = text.split("\n");
|
|
5793
|
+
const warnings = [];
|
|
5794
|
+
const pattern2 = extractPatternSlug(lines2, warnings);
|
|
5795
|
+
const theme2 = extractMetaField(lines2, "Theme");
|
|
5796
|
+
const recipePath = extractRecipePath(text, handoffPath, warnings);
|
|
5797
|
+
const primitives = extractPrimitives(text, warnings);
|
|
5798
|
+
const { screens, mockShapes } = recipePath && existsSync19(recipePath) ? extractRecipeArtifacts(recipePath, warnings) : { screens: [], mockShapes: [] };
|
|
5799
|
+
return { pattern: pattern2, theme: theme2, recipePath, primitives, screens, mockShapes, warnings };
|
|
5800
|
+
}
|
|
5801
|
+
function extractPatternSlug(lines2, warnings) {
|
|
5802
|
+
const h1 = lines2.find((l) => /^#\s/.test(l));
|
|
5803
|
+
if (!h1) {
|
|
5804
|
+
warnings.push("Handoff has no H1 \u2014 pattern slug fell back to 'sandbox'");
|
|
5805
|
+
return "sandbox";
|
|
5806
|
+
}
|
|
5807
|
+
const m = h1.match(/^#\s+(?:Design\s+handoff\s+[—-]\s+)?(.+?)\s*$/i);
|
|
5808
|
+
if (!m) {
|
|
5809
|
+
warnings.push(`Could not parse pattern from H1: ${h1}`);
|
|
5810
|
+
return "sandbox";
|
|
5811
|
+
}
|
|
5812
|
+
return m[1].trim().toLowerCase().replace(/\s+/g, "-");
|
|
5813
|
+
}
|
|
5814
|
+
function extractMetaField(lines2, field) {
|
|
5815
|
+
const re = new RegExp(`^\\*\\*${field}:\\*\\*\\s+([^\\s(*]+)`, "i");
|
|
5816
|
+
for (const l of lines2) {
|
|
5817
|
+
const m = l.match(re);
|
|
5818
|
+
if (m) return m[1].trim();
|
|
5819
|
+
}
|
|
5820
|
+
return void 0;
|
|
5821
|
+
}
|
|
5822
|
+
function extractRecipePath(text, handoffPath, warnings) {
|
|
5823
|
+
const m = text.match(/##\s+Recipe[\s\S]*?\[`?([^\]`]+)`?\]\(([^)]+)\)/i);
|
|
5824
|
+
if (!m) {
|
|
5825
|
+
warnings.push("Could not locate Recipe link in handoff");
|
|
5826
|
+
return void 0;
|
|
5827
|
+
}
|
|
5828
|
+
const href = m[2].trim();
|
|
5829
|
+
if (isAbsolute(href)) return href;
|
|
5830
|
+
return resolve14(dirname8(handoffPath), href);
|
|
5831
|
+
}
|
|
5832
|
+
var STATUS_PATTERNS = [
|
|
5833
|
+
{ re: /NEW\s+gap/i, status: "gap-new" },
|
|
5834
|
+
{ re: /blocked-by/i, status: "gap-new" },
|
|
5835
|
+
{ re: /in\s+flight/i, status: "gap-inflight" },
|
|
5836
|
+
{ re: /shipped/i, status: "shipped" }
|
|
5837
|
+
];
|
|
5838
|
+
function classifyStatus(cell) {
|
|
5839
|
+
for (const { re, status } of STATUS_PATTERNS) {
|
|
5840
|
+
if (re.test(cell)) return status;
|
|
5841
|
+
}
|
|
5842
|
+
return "shipped";
|
|
5843
|
+
}
|
|
5844
|
+
function extractVITicket(cell) {
|
|
5845
|
+
const m = cell.match(/VI-(\d+)/i);
|
|
5846
|
+
return m ? `VI-${m[1]}` : void 0;
|
|
5847
|
+
}
|
|
5848
|
+
function extractPrimitives(text, warnings) {
|
|
5849
|
+
const primitives = [];
|
|
5850
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5851
|
+
const tableMatches = text.matchAll(/\|\s*Component\s*\|[^\n]*\n\|[^\n]*\n((?:\|[^\n]*\n?)+)/gi);
|
|
5852
|
+
for (const tm of tableMatches) {
|
|
5853
|
+
const rows = tm[1].split("\n").map((r) => r.trim()).filter((r) => r.startsWith("|"));
|
|
5854
|
+
for (const row of rows) {
|
|
5855
|
+
const cells = row.split("|").map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
|
|
5856
|
+
if (cells.length < 2) continue;
|
|
5857
|
+
const rawName = cells[0].replace(/`/g, "").trim();
|
|
5858
|
+
const nameCell = rawName.replace(/\s*\([^)]*\)\s*/g, "").trim();
|
|
5859
|
+
if (!nameCell || nameCell.includes("---") || nameCell === "Component") continue;
|
|
5860
|
+
if (seen.has(nameCell)) continue;
|
|
5861
|
+
seen.add(nameCell);
|
|
5862
|
+
const joined = cells.slice(1).join(" | ");
|
|
5863
|
+
const status = classifyStatus(joined);
|
|
5864
|
+
const viTicket = status === "shipped" ? void 0 : extractVITicket(joined);
|
|
5865
|
+
const kindCell = cells[1] || "";
|
|
5866
|
+
const annotation = rawName.match(/\(([^)]+)\)/)?.[1] ?? "";
|
|
5867
|
+
const kind = /block/i.test(kindCell) || /block/i.test(annotation) ? "block" : /primitive/i.test(kindCell) || /primitive/i.test(annotation) ? "primitive" : void 0;
|
|
5868
|
+
primitives.push({ name: nameCell, status, viTicket, kind });
|
|
5869
|
+
}
|
|
5870
|
+
}
|
|
5871
|
+
if (primitives.length === 0) {
|
|
5872
|
+
warnings.push("Handoff has no Component inventory rows \u2014 sandbox will be empty");
|
|
5873
|
+
}
|
|
5874
|
+
return primitives;
|
|
5875
|
+
}
|
|
5876
|
+
function extractRecipeArtifacts(recipePath, warnings) {
|
|
5877
|
+
const text = readFileSync22(recipePath, "utf-8");
|
|
5878
|
+
const screens = extractScreens(text);
|
|
5879
|
+
const mockShapes = extractMockFields(text, warnings);
|
|
5880
|
+
return { screens, mockShapes };
|
|
5881
|
+
}
|
|
5882
|
+
function extractScreens(text) {
|
|
5883
|
+
const out = [];
|
|
5884
|
+
const matches = text.matchAll(/^###\s+Screen\s+\d+:\s+(.+?)(?:\s*\(`([^`]+)`\))?\s*$/gim);
|
|
5885
|
+
let idx = 1;
|
|
5886
|
+
for (const m of matches) {
|
|
5887
|
+
const title = m[1].trim();
|
|
5888
|
+
const route = m[2]?.trim();
|
|
5889
|
+
const slug2 = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
5890
|
+
const name = slug2 || `screen-${idx}`;
|
|
5891
|
+
out.push({ name, title, route });
|
|
5892
|
+
idx++;
|
|
5893
|
+
}
|
|
5894
|
+
return out;
|
|
5895
|
+
}
|
|
5896
|
+
function extractMockFields(text, warnings) {
|
|
5897
|
+
const m = text.match(/##\s+Inputs[^\n]*\n+([\s\S]*?)(?=\n##\s|\n$)/i);
|
|
5898
|
+
if (!m) {
|
|
5899
|
+
warnings.push(
|
|
5900
|
+
"Recipe has no 'Inputs from generation skill' section \u2014 mock data will be empty"
|
|
5901
|
+
);
|
|
5902
|
+
return [];
|
|
5903
|
+
}
|
|
5904
|
+
const rows = m[1].split("\n").filter((r) => r.startsWith("|"));
|
|
5905
|
+
if (rows.length < 2) return [];
|
|
5906
|
+
const out = [];
|
|
5907
|
+
for (const row of rows.slice(2)) {
|
|
5908
|
+
const cells = row.split("|").map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
|
|
5909
|
+
if (cells.length < 2) continue;
|
|
5910
|
+
const field = cells[0].replace(/`/g, "").trim();
|
|
5911
|
+
if (!field) continue;
|
|
5912
|
+
const type = cells[1].replace(/`/g, "").trim();
|
|
5913
|
+
const description = cells[2]?.trim();
|
|
5914
|
+
out.push({ field, type, description });
|
|
5915
|
+
}
|
|
5916
|
+
return out;
|
|
5917
|
+
}
|
|
5918
|
+
|
|
5919
|
+
// src/commands/sandbox/ports.ts
|
|
5920
|
+
import { createServer } from "net";
|
|
5921
|
+
var RESERVED_PORTS = /* @__PURE__ */ new Set([3e3]);
|
|
5922
|
+
var MAX_PORT_PROBES = 50;
|
|
5923
|
+
var DEFAULT_START_PORT = 4060;
|
|
5924
|
+
async function findOpenPort(start = DEFAULT_START_PORT) {
|
|
5925
|
+
let port = start;
|
|
5926
|
+
for (let i = 0; i < MAX_PORT_PROBES; i++) {
|
|
5927
|
+
if (RESERVED_PORTS.has(port)) {
|
|
5928
|
+
port++;
|
|
5929
|
+
continue;
|
|
5930
|
+
}
|
|
5931
|
+
const free = await tryPort(port);
|
|
5932
|
+
if (free) return port;
|
|
5933
|
+
port++;
|
|
5934
|
+
}
|
|
5935
|
+
throw new Error(
|
|
5936
|
+
`Could not find an open port between ${start} and ${start + MAX_PORT_PROBES} (port 3000 excluded)`
|
|
5937
|
+
);
|
|
5938
|
+
}
|
|
5939
|
+
function tryPort(port) {
|
|
5940
|
+
return new Promise((resolveProbe) => {
|
|
5941
|
+
const server = createServer();
|
|
5942
|
+
server.once("error", () => resolveProbe(false));
|
|
5943
|
+
server.once("listening", () => {
|
|
5944
|
+
server.close(() => resolveProbe(true));
|
|
5945
|
+
});
|
|
5946
|
+
server.listen(port, "127.0.0.1");
|
|
5947
|
+
});
|
|
5948
|
+
}
|
|
5949
|
+
|
|
5950
|
+
// src/commands/sandbox/scaffold.ts
|
|
5951
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync12, existsSync as existsSync20, readdirSync as readdirSync12 } from "fs";
|
|
5952
|
+
import { join as join20 } from "path";
|
|
5953
|
+
|
|
5954
|
+
// src/commands/sandbox/templates.ts
|
|
5955
|
+
var SANDBOX_PACKAGE_VERSION = "16.2.6";
|
|
5956
|
+
var SANDBOX_REACT_VERSION = "19.0.0";
|
|
5957
|
+
var SANDBOX_REACT_DOM_VERSION = "19.0.0";
|
|
5958
|
+
var SANDBOX_PLAYWRIGHT_VERSION = "1.49.0";
|
|
5959
|
+
var SANDBOX_PIXELMATCH_VERSION = "6.0.0";
|
|
5960
|
+
var SANDBOX_PNGJS_VERSION = "7.0.0";
|
|
5961
|
+
function packageJsonTemplate(name) {
|
|
5962
|
+
return `${JSON.stringify(
|
|
5963
|
+
{
|
|
5964
|
+
name: `sandbox-${name}`,
|
|
5965
|
+
version: "0.0.0",
|
|
5966
|
+
private: true,
|
|
5967
|
+
type: "module",
|
|
5968
|
+
scripts: {
|
|
5969
|
+
dev: "next dev",
|
|
5970
|
+
build: "next build",
|
|
5971
|
+
start: "next start"
|
|
5972
|
+
},
|
|
5973
|
+
dependencies: {
|
|
5974
|
+
next: SANDBOX_PACKAGE_VERSION,
|
|
5975
|
+
react: SANDBOX_REACT_VERSION,
|
|
5976
|
+
"react-dom": SANDBOX_REACT_DOM_VERSION,
|
|
5977
|
+
"@loworbitstudio/visor-core": "*",
|
|
5978
|
+
"@loworbitstudio/visor-theme-engine": "*"
|
|
5979
|
+
},
|
|
5980
|
+
devDependencies: {
|
|
5981
|
+
"@types/node": "^22.0.0",
|
|
5982
|
+
"@types/react": "^19.0.0",
|
|
5983
|
+
"@types/react-dom": "^19.0.0",
|
|
5984
|
+
typescript: "^5.7.2",
|
|
5985
|
+
"@playwright/test": `^${SANDBOX_PLAYWRIGHT_VERSION}`,
|
|
5986
|
+
pixelmatch: `^${SANDBOX_PIXELMATCH_VERSION}`,
|
|
5987
|
+
pngjs: `^${SANDBOX_PNGJS_VERSION}`
|
|
5988
|
+
}
|
|
5989
|
+
},
|
|
5990
|
+
null,
|
|
5991
|
+
2
|
|
5992
|
+
)}
|
|
5993
|
+
`;
|
|
5994
|
+
}
|
|
5995
|
+
function tsconfigTemplate() {
|
|
5996
|
+
return `${JSON.stringify(
|
|
5997
|
+
{
|
|
5998
|
+
compilerOptions: {
|
|
5999
|
+
target: "ES2022",
|
|
6000
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
6001
|
+
allowJs: false,
|
|
6002
|
+
skipLibCheck: true,
|
|
6003
|
+
strict: true,
|
|
6004
|
+
noEmit: true,
|
|
6005
|
+
esModuleInterop: true,
|
|
6006
|
+
module: "esnext",
|
|
6007
|
+
moduleResolution: "bundler",
|
|
6008
|
+
resolveJsonModule: true,
|
|
6009
|
+
isolatedModules: true,
|
|
6010
|
+
jsx: "preserve",
|
|
6011
|
+
incremental: true,
|
|
6012
|
+
plugins: [{ name: "next" }],
|
|
6013
|
+
paths: { "@/*": ["./*"] }
|
|
6014
|
+
},
|
|
6015
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
|
6016
|
+
exclude: ["node_modules", "captures", ".next"]
|
|
6017
|
+
},
|
|
6018
|
+
null,
|
|
6019
|
+
2
|
|
6020
|
+
)}
|
|
6021
|
+
`;
|
|
6022
|
+
}
|
|
6023
|
+
function nextConfigTemplate() {
|
|
6024
|
+
return `import type { NextConfig } from "next"
|
|
6025
|
+
|
|
6026
|
+
const nextConfig: NextConfig = {
|
|
6027
|
+
reactStrictMode: true,
|
|
6028
|
+
// Sandbox is local-only \u2014 disable production telemetry and image opt to keep startup fast.
|
|
6029
|
+
images: { unoptimized: true },
|
|
6030
|
+
}
|
|
6031
|
+
|
|
6032
|
+
export default nextConfig
|
|
6033
|
+
`;
|
|
6034
|
+
}
|
|
6035
|
+
function nextEnvDtsTemplate() {
|
|
6036
|
+
return `/// <reference types="next" />
|
|
6037
|
+
/// <reference types="next/image-types/global" />
|
|
6038
|
+
`;
|
|
6039
|
+
}
|
|
6040
|
+
function gitignoreTemplate() {
|
|
6041
|
+
return [
|
|
6042
|
+
"node_modules",
|
|
6043
|
+
".next",
|
|
6044
|
+
"out",
|
|
6045
|
+
"captures/pending",
|
|
6046
|
+
"captures/diffs",
|
|
6047
|
+
"*.log",
|
|
6048
|
+
""
|
|
6049
|
+
].join("\n");
|
|
6050
|
+
}
|
|
6051
|
+
function rootLayoutTemplate(pattern2) {
|
|
6052
|
+
return `import "./globals.css"
|
|
6053
|
+
import { FOWT_SCRIPT } from "@loworbitstudio/visor-theme-engine/fowt"
|
|
6054
|
+
import type { Metadata } from "next"
|
|
6055
|
+
import type { ReactNode } from "react"
|
|
6056
|
+
|
|
6057
|
+
export const metadata: Metadata = {
|
|
6058
|
+
title: "Sandbox \u2014 ${pattern2}",
|
|
6059
|
+
description: "Visor sandbox for in-vivo primitive iteration.",
|
|
6060
|
+
}
|
|
6061
|
+
|
|
6062
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
6063
|
+
return (
|
|
6064
|
+
<html lang="en">
|
|
6065
|
+
<head>
|
|
6066
|
+
<script>{FOWT_SCRIPT}</script>
|
|
6067
|
+
</head>
|
|
6068
|
+
<body>{children}</body>
|
|
6069
|
+
</html>
|
|
6070
|
+
)
|
|
6071
|
+
}
|
|
6072
|
+
`;
|
|
6073
|
+
}
|
|
6074
|
+
function globalsCssPlaceholder() {
|
|
6075
|
+
return `/* This file is overwritten by 'visor theme apply' during sandbox init. */
|
|
6076
|
+
:root {
|
|
6077
|
+
--bg-surface: #f7f7f8;
|
|
6078
|
+
--text-primary: #111827;
|
|
6079
|
+
}
|
|
6080
|
+
body { background: var(--bg-surface); color: var(--text-primary); margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
6081
|
+
`;
|
|
6082
|
+
}
|
|
6083
|
+
function indexPageTemplate() {
|
|
6084
|
+
return `import Link from "next/link"
|
|
6085
|
+
import { manifest } from "@/lib/sandbox-manifest"
|
|
6086
|
+
|
|
6087
|
+
export default function SandboxIndex() {
|
|
6088
|
+
return (
|
|
6089
|
+
<main style={{ padding: "32px", maxWidth: "880px", margin: "0 auto" }}>
|
|
6090
|
+
<h1>Sandbox: {manifest.pattern}</h1>
|
|
6091
|
+
<p style={{ color: "var(--text-secondary, #555)" }}>{manifest.primitives.length} primitives \xB7 {manifest.screens.length} screens</p>
|
|
6092
|
+
<section>
|
|
6093
|
+
<h2>Primitives</h2>
|
|
6094
|
+
<ul>
|
|
6095
|
+
{manifest.primitives.map((p) => (
|
|
6096
|
+
<li key={p.name}><Link href={\`/primitives/\${p.name}\`}>{p.name}</Link> <small>({p.status}{p.viTicket ? " \xB7 " + p.viTicket : ""})</small></li>
|
|
6097
|
+
))}
|
|
6098
|
+
</ul>
|
|
6099
|
+
</section>
|
|
6100
|
+
<section>
|
|
6101
|
+
<h2>Screens</h2>
|
|
6102
|
+
<ul>
|
|
6103
|
+
{manifest.screens.map((s) => (
|
|
6104
|
+
<li key={s.name}><Link href={\`/screens/\${s.name}\`}>{s.title}</Link></li>
|
|
6105
|
+
))}
|
|
6106
|
+
</ul>
|
|
6107
|
+
</section>
|
|
6108
|
+
</main>
|
|
6109
|
+
)
|
|
6110
|
+
}
|
|
6111
|
+
`;
|
|
6112
|
+
}
|
|
6113
|
+
function primitiveRouteTemplate() {
|
|
6114
|
+
return `import { notFound } from "next/navigation"
|
|
6115
|
+
import { manifest } from "@/lib/sandbox-manifest"
|
|
6116
|
+
import { PrimitiveSample } from "@/components/sandbox-sample"
|
|
6117
|
+
|
|
6118
|
+
interface Params { name: string }
|
|
6119
|
+
|
|
6120
|
+
export function generateStaticParams() {
|
|
6121
|
+
return manifest.primitives.map((p) => ({ name: p.name }))
|
|
6122
|
+
}
|
|
6123
|
+
|
|
6124
|
+
export default async function PrimitivePage({ params }: { params: Promise<Params> }) {
|
|
6125
|
+
const { name } = await params
|
|
6126
|
+
const entry = manifest.primitives.find((p) => p.name === name)
|
|
6127
|
+
if (!entry) notFound()
|
|
6128
|
+
return (
|
|
6129
|
+
<main style={{ padding: "32px", maxWidth: "880px", margin: "0 auto" }}>
|
|
6130
|
+
<h1>{entry.name}</h1>
|
|
6131
|
+
<p style={{ color: "var(--text-secondary, #555)" }}>{entry.status}{entry.viTicket ? " \xB7 " + entry.viTicket : ""}</p>
|
|
6132
|
+
<PrimitiveSample name={entry.name} />
|
|
6133
|
+
</main>
|
|
6134
|
+
)
|
|
6135
|
+
}
|
|
6136
|
+
`;
|
|
6137
|
+
}
|
|
6138
|
+
function screenRouteTemplate() {
|
|
6139
|
+
return `import { notFound } from "next/navigation"
|
|
6140
|
+
import { manifest } from "@/lib/sandbox-manifest"
|
|
6141
|
+
import { ScreenSample } from "@/components/sandbox-sample"
|
|
6142
|
+
|
|
6143
|
+
interface Params { name: string }
|
|
6144
|
+
|
|
6145
|
+
export function generateStaticParams() {
|
|
6146
|
+
return manifest.screens.map((s) => ({ name: s.name }))
|
|
6147
|
+
}
|
|
6148
|
+
|
|
6149
|
+
export default async function ScreenPage({ params }: { params: Promise<Params> }) {
|
|
6150
|
+
const { name } = await params
|
|
6151
|
+
const entry = manifest.screens.find((s) => s.name === name)
|
|
6152
|
+
if (!entry) notFound()
|
|
6153
|
+
return (
|
|
6154
|
+
<main style={{ padding: "32px", maxWidth: "1280px", margin: "0 auto" }}>
|
|
6155
|
+
<h1>{entry.title}</h1>
|
|
6156
|
+
{entry.route ? <p style={{ color: "var(--text-secondary, #555)" }}><code>{entry.route}</code></p> : null}
|
|
6157
|
+
<ScreenSample name={entry.name} />
|
|
6158
|
+
</main>
|
|
6159
|
+
)
|
|
6160
|
+
}
|
|
6161
|
+
`;
|
|
6162
|
+
}
|
|
6163
|
+
function sandboxManifestModule(manifest) {
|
|
6164
|
+
const payload = {
|
|
6165
|
+
pattern: manifest.pattern,
|
|
6166
|
+
theme: manifest.theme ?? null,
|
|
6167
|
+
primitives: manifest.primitives.map((p) => ({
|
|
6168
|
+
name: p.name,
|
|
6169
|
+
status: p.status,
|
|
6170
|
+
viTicket: p.viTicket ?? null,
|
|
6171
|
+
kind: p.kind ?? null
|
|
6172
|
+
})),
|
|
6173
|
+
screens: manifest.screens.map((s) => ({
|
|
6174
|
+
name: s.name,
|
|
6175
|
+
title: s.title,
|
|
6176
|
+
route: s.route ?? null
|
|
6177
|
+
}))
|
|
6178
|
+
};
|
|
6179
|
+
return `export const manifest = ${JSON.stringify(payload, null, 2)} as const
|
|
6180
|
+
|
|
6181
|
+
export type ManifestPrimitive = (typeof manifest)["primitives"][number]
|
|
6182
|
+
export type ManifestScreen = (typeof manifest)["screens"][number]
|
|
6183
|
+
`;
|
|
6184
|
+
}
|
|
6185
|
+
function sandboxMocksModule(fields) {
|
|
6186
|
+
if (fields.length === 0) {
|
|
6187
|
+
return `// No mock fields declared in recipe. Hand-edit this file to add fixtures consumed by screens/<name>/page.tsx.
|
|
6188
|
+
export const mocks: Record<string, unknown> = {}
|
|
6189
|
+
`;
|
|
6190
|
+
}
|
|
6191
|
+
const lines2 = [
|
|
6192
|
+
"// Auto-generated mock fixtures from the recipe's 'Inputs from generation skill' table.",
|
|
6193
|
+
"// Hand-edit values to provide realistic data for screens. The shapes below are TODO stubs.",
|
|
6194
|
+
"",
|
|
6195
|
+
"export const mocks = {"
|
|
6196
|
+
];
|
|
6197
|
+
for (const f of fields) {
|
|
6198
|
+
const isArray = /\[\]\s*$/.test(f.type);
|
|
6199
|
+
const stub = isArray ? "[]" : f.field === "currentUser" ? '{ id: "u1", role: "owner" }' : '""';
|
|
6200
|
+
const safeKey = /^[a-zA-Z_$][\w$]*$/.test(f.field) ? f.field : `["${f.field.replace(/"/g, '\\"')}"]`;
|
|
6201
|
+
const desc = f.description ? ` // ${f.description.slice(0, 80)}` : "";
|
|
6202
|
+
lines2.push(` ${safeKey}: ${stub} as unknown,${desc}`);
|
|
6203
|
+
}
|
|
6204
|
+
lines2.push("} satisfies Record<string, unknown>", "");
|
|
6205
|
+
return lines2.join("\n");
|
|
6206
|
+
}
|
|
6207
|
+
function sandboxSampleComponent() {
|
|
6208
|
+
return `import { manifest, type ManifestPrimitive } from "@/lib/sandbox-manifest"
|
|
6209
|
+
import { mocks } from "@/lib/sandbox-mocks"
|
|
6210
|
+
|
|
6211
|
+
void mocks
|
|
6212
|
+
|
|
6213
|
+
/** Dynamic primitive sample. v1 shows the primitive name in a frame; operator hand-edits routes for richer compositions. */
|
|
6214
|
+
export function PrimitiveSample({ name }: { name: string }) {
|
|
6215
|
+
const entry = manifest.primitives.find((p) => p.name === name) as ManifestPrimitive | undefined
|
|
6216
|
+
if (!entry) return null
|
|
6217
|
+
return (
|
|
6218
|
+
<section data-sandbox-primitive={entry.name} style={{ padding: "24px", border: "1px solid var(--border-default, #e5e7eb)", borderRadius: "12px", marginTop: "16px" }}>
|
|
6219
|
+
<p style={{ margin: 0, fontFamily: "var(--font-mono, monospace)", fontSize: "var(--text-sm, 13px)", color: "var(--text-secondary, #555)" }}>
|
|
6220
|
+
Operator: hand-edit <code>app/primitives/[name]/page.tsx</code> to render this primitive in real states.
|
|
6221
|
+
</p>
|
|
6222
|
+
</section>
|
|
6223
|
+
)
|
|
6224
|
+
}
|
|
6225
|
+
|
|
6226
|
+
export function ScreenSample({ name }: { name: string }) {
|
|
6227
|
+
const entry = manifest.screens.find((s) => s.name === name)
|
|
6228
|
+
if (!entry) return null
|
|
6229
|
+
return (
|
|
6230
|
+
<section data-sandbox-screen={entry.name} style={{ padding: "24px", border: "1px solid var(--border-default, #e5e7eb)", borderRadius: "12px", marginTop: "16px" }}>
|
|
6231
|
+
<p style={{ margin: 0, fontFamily: "var(--font-mono, monospace)", fontSize: "var(--text-sm, 13px)", color: "var(--text-secondary, #555)" }}>
|
|
6232
|
+
Operator: hand-edit <code>app/screens/[name]/page.tsx</code> to assemble the {entry.title} composition.
|
|
6233
|
+
</p>
|
|
6234
|
+
</section>
|
|
6235
|
+
)
|
|
6236
|
+
}
|
|
6237
|
+
`;
|
|
6238
|
+
}
|
|
6239
|
+
function stubTemplate(entry) {
|
|
6240
|
+
const componentName = toPascalCase(entry.name);
|
|
6241
|
+
const ticket = entry.viTicket ?? "VI-???";
|
|
6242
|
+
return `// GAP STUB \u2014 ${ticket}. Hand-edit this file to sketch the primitive in place.
|
|
6243
|
+
import type { ReactNode } from "react"
|
|
6244
|
+
|
|
6245
|
+
export interface ${componentName}Props {
|
|
6246
|
+
children?: ReactNode
|
|
6247
|
+
[key: string]: unknown
|
|
6248
|
+
}
|
|
6249
|
+
|
|
6250
|
+
export function ${componentName}(props: ${componentName}Props) {
|
|
6251
|
+
return (
|
|
6252
|
+
<div
|
|
6253
|
+
role="img"
|
|
6254
|
+
aria-label="GAP: ${ticket} \u2014 ${entry.name}"
|
|
6255
|
+
data-stub-component="${entry.name}"
|
|
6256
|
+
style={{
|
|
6257
|
+
border: "2px dashed var(--border-strong, #888)",
|
|
6258
|
+
padding: "var(--spacing-4, 16px)",
|
|
6259
|
+
borderRadius: "var(--radius-md, 8px)",
|
|
6260
|
+
color: "var(--text-secondary, #555)",
|
|
6261
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
6262
|
+
fontSize: "var(--text-sm, 12px)",
|
|
6263
|
+
display: "inline-flex",
|
|
6264
|
+
flexDirection: "column",
|
|
6265
|
+
gap: "4px",
|
|
6266
|
+
minWidth: "120px",
|
|
6267
|
+
textAlign: "center",
|
|
6268
|
+
}}
|
|
6269
|
+
>
|
|
6270
|
+
<strong>GAP: ${ticket}</strong>
|
|
6271
|
+
<span>${entry.name}</span>
|
|
6272
|
+
<span style={{ opacity: 0.7 }}>see visual spec</span>
|
|
6273
|
+
{props.children}
|
|
6274
|
+
</div>
|
|
6275
|
+
)
|
|
6276
|
+
}
|
|
6277
|
+
`;
|
|
6278
|
+
}
|
|
6279
|
+
function readmeTemplate(manifest, port) {
|
|
6280
|
+
const shipped = manifest.primitives.filter((p) => p.status === "shipped" || p.status === "gap-inflight");
|
|
6281
|
+
const gaps = manifest.primitives.filter((p) => p.status === "gap-new");
|
|
6282
|
+
return [
|
|
6283
|
+
`# Sandbox: ${manifest.pattern}`,
|
|
6284
|
+
"",
|
|
6285
|
+
"Scaffolded by `visor sandbox init`. Iterate visually here, then capture approved states with `visor sandbox approve`.",
|
|
6286
|
+
"",
|
|
6287
|
+
"## Running",
|
|
6288
|
+
"",
|
|
6289
|
+
"```bash",
|
|
6290
|
+
`npx visor sandbox dev --name ${manifest.pattern}`,
|
|
6291
|
+
"```",
|
|
6292
|
+
"",
|
|
6293
|
+
`Dev server on **port ${port}** (port 3000 is reserved).`,
|
|
6294
|
+
"",
|
|
6295
|
+
`## Primitives (${shipped.length} shipped / ${gaps.length} gaps)`,
|
|
6296
|
+
"",
|
|
6297
|
+
...shipped.map((p) => `- \`${p.name}\` \u2014 shipped`),
|
|
6298
|
+
...gaps.map((p) => `- \`${p.name}\` \u2014 **GAP** (${p.viTicket ?? "VI-???"}) \u2014 stub at \`components/stubs/${p.name}.tsx\``),
|
|
6299
|
+
"",
|
|
6300
|
+
"## Screens",
|
|
6301
|
+
"",
|
|
6302
|
+
...manifest.screens.length === 0 ? ["- (none declared in recipe)"] : manifest.screens.map((s) => `- \`/screens/${s.name}\` \u2014 ${s.title}${s.route ? ` (target route: \`${s.route}\`)` : ""}`),
|
|
6303
|
+
"",
|
|
6304
|
+
"## Capturing",
|
|
6305
|
+
"",
|
|
6306
|
+
"```bash",
|
|
6307
|
+
`npx visor sandbox approve --name ${manifest.pattern} # writes captures/approved/*.png`,
|
|
6308
|
+
`npx visor sandbox approve --name ${manifest.pattern} --diff # pixel-diff vs prior approved`,
|
|
6309
|
+
"```",
|
|
6310
|
+
""
|
|
6311
|
+
].join("\n");
|
|
6312
|
+
}
|
|
6313
|
+
function captureScriptTemplate() {
|
|
6314
|
+
return `// Auto-generated by 'visor sandbox approve'. Do not edit by hand \u2014 rewrite via approve.
|
|
6315
|
+
import { chromium } from "@playwright/test"
|
|
6316
|
+
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"
|
|
6317
|
+
import { existsSync } from "node:fs"
|
|
6318
|
+
import { join, dirname } from "node:path"
|
|
6319
|
+
import { fileURLToPath } from "node:url"
|
|
6320
|
+
import pixelmatch from "pixelmatch"
|
|
6321
|
+
import { PNG } from "pngjs"
|
|
6322
|
+
|
|
6323
|
+
const sandboxDir = dirname(fileURLToPath(import.meta.url))
|
|
6324
|
+
const configPath = join(sandboxDir, "sandbox.json")
|
|
6325
|
+
const config = JSON.parse(await readFile(configPath, "utf-8"))
|
|
6326
|
+
|
|
6327
|
+
const diffMode = process.argv.includes("--diff")
|
|
6328
|
+
const port = Number(process.env.SANDBOX_PORT ?? config.port)
|
|
6329
|
+
const baseUrl = \`http://localhost:\${port}\`
|
|
6330
|
+
|
|
6331
|
+
const routes = ["/", ...config.primitives.map((p) => \`/primitives/\${p.name}\`), ...config.screens.map((s) => \`/screens/\${s.name}\`)]
|
|
6332
|
+
|
|
6333
|
+
const approvedDir = join(sandboxDir, "captures", "approved")
|
|
6334
|
+
const pendingDir = join(sandboxDir, "captures", "pending")
|
|
6335
|
+
const diffsDir = join(sandboxDir, "captures", "diffs")
|
|
6336
|
+
await mkdir(approvedDir, { recursive: true })
|
|
6337
|
+
await mkdir(pendingDir, { recursive: true })
|
|
6338
|
+
if (diffMode) await mkdir(diffsDir, { recursive: true })
|
|
6339
|
+
|
|
6340
|
+
function slugify(route) {
|
|
6341
|
+
if (route === "/") return "index"
|
|
6342
|
+
return route.replace(/^\\//, "").replace(/\\//g, "__")
|
|
6343
|
+
}
|
|
6344
|
+
|
|
6345
|
+
const browser = await chromium.launch()
|
|
6346
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } })
|
|
6347
|
+
const page = await context.newPage()
|
|
6348
|
+
const results = []
|
|
6349
|
+
|
|
6350
|
+
for (const route of routes) {
|
|
6351
|
+
const slug = slugify(route)
|
|
6352
|
+
const targetDir = diffMode ? pendingDir : approvedDir
|
|
6353
|
+
const pngPath = join(targetDir, \`\${slug}.png\`)
|
|
6354
|
+
await page.goto(baseUrl + route, { waitUntil: "networkidle" })
|
|
6355
|
+
const buf = await page.screenshot({ fullPage: true })
|
|
6356
|
+
await writeFile(pngPath, buf)
|
|
6357
|
+
results.push({ route, png: pngPath })
|
|
6358
|
+
|
|
6359
|
+
if (diffMode) {
|
|
6360
|
+
const baselinePath = join(approvedDir, \`\${slug}.png\`)
|
|
6361
|
+
if (!existsSync(baselinePath)) {
|
|
6362
|
+
results[results.length - 1].diff = "no-baseline"
|
|
6363
|
+
continue
|
|
6364
|
+
}
|
|
6365
|
+
const baselineBuf = await readFile(baselinePath)
|
|
6366
|
+
const baseline = PNG.sync.read(baselineBuf)
|
|
6367
|
+
const candidate = PNG.sync.read(buf)
|
|
6368
|
+
if (baseline.width !== candidate.width || baseline.height !== candidate.height) {
|
|
6369
|
+
const diffPath = join(diffsDir, \`\${slug}.diff.png\`)
|
|
6370
|
+
await writeFile(diffPath, buf)
|
|
6371
|
+
results[results.length - 1].diff = diffPath
|
|
6372
|
+
results[results.length - 1].dimensionsMismatch = true
|
|
6373
|
+
continue
|
|
6374
|
+
}
|
|
6375
|
+
const diffPng = new PNG({ width: baseline.width, height: baseline.height })
|
|
6376
|
+
const pixels = pixelmatch(
|
|
6377
|
+
baseline.data,
|
|
6378
|
+
candidate.data,
|
|
6379
|
+
diffPng.data,
|
|
6380
|
+
baseline.width,
|
|
6381
|
+
baseline.height,
|
|
6382
|
+
{ threshold: 0.1 }
|
|
6383
|
+
)
|
|
6384
|
+
if (pixels > 0) {
|
|
6385
|
+
const diffPath = join(diffsDir, \`\${slug}.diff.png\`)
|
|
6386
|
+
await writeFile(diffPath, PNG.sync.write(diffPng))
|
|
6387
|
+
results[results.length - 1].diff = diffPath
|
|
6388
|
+
results[results.length - 1].changedPixels = pixels
|
|
6389
|
+
}
|
|
6390
|
+
}
|
|
6391
|
+
}
|
|
6392
|
+
|
|
6393
|
+
await browser.close()
|
|
6394
|
+
await writeFile(join(sandboxDir, "captures", "last-run.json"), JSON.stringify({ diffMode, baseUrl, routes: results }, null, 2))
|
|
6395
|
+
console.log(JSON.stringify({ ok: true, diffMode, routes: results }, null, 2))
|
|
6396
|
+
`;
|
|
6397
|
+
}
|
|
6398
|
+
function toPascalCase(s) {
|
|
6399
|
+
return s.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
6400
|
+
}
|
|
6401
|
+
|
|
6402
|
+
// src/commands/sandbox/scaffold.ts
|
|
6403
|
+
function writeScaffold(sandboxDir, manifest, port) {
|
|
6404
|
+
mkdirSync7(sandboxDir, { recursive: true });
|
|
6405
|
+
const created = [];
|
|
6406
|
+
const writeIfNew = (rel, contents) => {
|
|
6407
|
+
const full = join20(sandboxDir, rel);
|
|
6408
|
+
mkdirSync7(dirOf(full), { recursive: true });
|
|
6409
|
+
writeFileSync12(full, contents, "utf-8");
|
|
6410
|
+
created.push(rel);
|
|
6411
|
+
};
|
|
6412
|
+
writeIfNew("package.json", packageJsonTemplate(manifest.pattern));
|
|
6413
|
+
writeIfNew("tsconfig.json", tsconfigTemplate());
|
|
6414
|
+
writeIfNew("next.config.ts", nextConfigTemplate());
|
|
6415
|
+
writeIfNew("next-env.d.ts", nextEnvDtsTemplate());
|
|
6416
|
+
writeIfNew(".gitignore", gitignoreTemplate());
|
|
6417
|
+
writeIfNew("README.md", readmeTemplate(manifest, port));
|
|
6418
|
+
writeIfNew("app/layout.tsx", rootLayoutTemplate(manifest.pattern));
|
|
6419
|
+
writeIfNew("app/globals.css", globalsCssPlaceholder());
|
|
6420
|
+
writeIfNew("app/page.tsx", indexPageTemplate());
|
|
6421
|
+
writeIfNew("app/primitives/[name]/page.tsx", primitiveRouteTemplate());
|
|
6422
|
+
writeIfNew("app/screens/[name]/page.tsx", screenRouteTemplate());
|
|
6423
|
+
writeIfNew("components/sandbox-sample.tsx", sandboxSampleComponent());
|
|
6424
|
+
writeIfNew("lib/sandbox-manifest.ts", sandboxManifestModule(manifest));
|
|
6425
|
+
writeIfNew("lib/sandbox-mocks.ts", sandboxMocksModule(manifest.mockShapes));
|
|
6426
|
+
for (const p of manifest.primitives) {
|
|
6427
|
+
if (p.status === "gap-new") {
|
|
6428
|
+
writeIfNew(`components/stubs/${p.name}.tsx`, stubTemplate(p));
|
|
6429
|
+
}
|
|
6430
|
+
}
|
|
6431
|
+
writeIfNew("playwright.capture.mjs", captureScriptTemplate());
|
|
6432
|
+
mkdirSync7(join20(sandboxDir, "captures", "approved"), { recursive: true });
|
|
6433
|
+
return { created };
|
|
6434
|
+
}
|
|
6435
|
+
function dirOf(p) {
|
|
6436
|
+
const idx = p.lastIndexOf("/");
|
|
6437
|
+
return idx === -1 ? "." : p.slice(0, idx);
|
|
6438
|
+
}
|
|
6439
|
+
function writeSandboxConfig(sandboxDir, manifest, port, options) {
|
|
6440
|
+
const config = {
|
|
6441
|
+
pattern: manifest.pattern,
|
|
6442
|
+
handoffPath: options.handoffPath,
|
|
6443
|
+
theme: options.theme,
|
|
6444
|
+
port,
|
|
6445
|
+
visorVersion: options.visorVersion,
|
|
6446
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6447
|
+
primitives: manifest.primitives.map((p) => ({
|
|
6448
|
+
name: p.name,
|
|
6449
|
+
status: p.status,
|
|
6450
|
+
viTicket: p.viTicket ?? null
|
|
6451
|
+
})),
|
|
6452
|
+
screens: manifest.screens.map((s) => ({ name: s.name, title: s.title, route: s.route ?? null }))
|
|
6453
|
+
};
|
|
6454
|
+
writeFileSync12(join20(sandboxDir, "sandbox.json"), JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
6455
|
+
}
|
|
6456
|
+
function sandboxIsEmpty(sandboxDir) {
|
|
6457
|
+
if (!existsSync20(sandboxDir)) return true;
|
|
6458
|
+
try {
|
|
6459
|
+
return readdirSync12(sandboxDir).filter((f) => !f.startsWith(".")).length === 0;
|
|
6460
|
+
} catch {
|
|
6461
|
+
return false;
|
|
6462
|
+
}
|
|
6463
|
+
}
|
|
6464
|
+
|
|
6465
|
+
// src/commands/sandbox/init.ts
|
|
6466
|
+
async function sandboxInitCommand(name, cwd, options) {
|
|
6467
|
+
const json = options.json ?? false;
|
|
6468
|
+
try {
|
|
6469
|
+
const result = await runInit(name, cwd, options);
|
|
6470
|
+
if (json) {
|
|
6471
|
+
console.log(JSON.stringify(result, null, 2));
|
|
6472
|
+
return;
|
|
6473
|
+
}
|
|
6474
|
+
logger.blank();
|
|
6475
|
+
logger.success(`Sandbox ready at ${result.sandboxDir}`);
|
|
6476
|
+
logger.info(`Port: ${result.port} (port 3000 reserved)`);
|
|
6477
|
+
logger.info(`Shipped primitives added: ${result.primitives?.shipped.length ?? 0}`);
|
|
6478
|
+
logger.info(`Gap stubs generated: ${result.primitives?.gaps.length ?? 0}`);
|
|
6479
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
6480
|
+
logger.blank();
|
|
6481
|
+
logger.warn("Warnings:");
|
|
6482
|
+
for (const w of result.warnings) logger.item(w);
|
|
6483
|
+
}
|
|
6484
|
+
logger.blank();
|
|
6485
|
+
logger.info("Next:");
|
|
6486
|
+
logger.item(`npx visor sandbox dev --name ${name}`);
|
|
6487
|
+
logger.item(`npx visor sandbox approve --name ${name}`);
|
|
6488
|
+
} catch (error) {
|
|
6489
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6490
|
+
if (json) {
|
|
6491
|
+
console.log(JSON.stringify({ success: false, error: message }, null, 2));
|
|
6492
|
+
} else {
|
|
6493
|
+
logger.error(message);
|
|
6494
|
+
}
|
|
6495
|
+
process.exit(1);
|
|
6496
|
+
}
|
|
6497
|
+
}
|
|
6498
|
+
async function runInit(name, cwd, options) {
|
|
6499
|
+
if (!name || !/^[a-z0-9][a-z0-9-_]*$/i.test(name)) {
|
|
6500
|
+
throw new Error(`Invalid sandbox name '${name}'. Use letters, digits, '-' or '_'.`);
|
|
6501
|
+
}
|
|
6502
|
+
const handoffPath = isAbsolute2(options.handoff) ? options.handoff : resolve15(cwd, options.handoff);
|
|
6503
|
+
if (!existsSync21(handoffPath)) {
|
|
6504
|
+
throw new Error(`Handoff manifest not found: ${handoffPath}`);
|
|
6505
|
+
}
|
|
6506
|
+
const manifest = parseHandoff(handoffPath);
|
|
6507
|
+
if (manifest.primitives.length === 0) {
|
|
6508
|
+
throw new Error(
|
|
6509
|
+
`Handoff has no primitives \u2014 refusing to scaffold an empty sandbox. Check the manifest at ${handoffPath}`
|
|
6510
|
+
);
|
|
6511
|
+
}
|
|
6512
|
+
const sandboxDir = join21(cwd, ".lo", "sandbox", name);
|
|
6513
|
+
if (!sandboxIsEmpty(sandboxDir)) {
|
|
6514
|
+
if (!options.overwrite) {
|
|
6515
|
+
throw new Error(
|
|
6516
|
+
`Sandbox directory not empty: ${sandboxDir}. Pass --overwrite to replace it.`
|
|
6517
|
+
);
|
|
6518
|
+
}
|
|
6519
|
+
rmSync2(sandboxDir, { recursive: true, force: true });
|
|
6520
|
+
}
|
|
6521
|
+
mkdirSync8(sandboxDir, { recursive: true });
|
|
6522
|
+
const port = await findOpenPort();
|
|
6523
|
+
writeScaffold(sandboxDir, manifest, port);
|
|
6524
|
+
if (!options.skipInstall) {
|
|
6525
|
+
runNpmInstall(sandboxDir, options.json ?? false);
|
|
6526
|
+
}
|
|
6527
|
+
applyThemeIfPossible(sandboxDir, manifest, options.theme, cwd, options.json ?? false);
|
|
6528
|
+
const known = loadKnownPrimitives();
|
|
6529
|
+
const shipped = [];
|
|
6530
|
+
for (const p of manifest.primitives) {
|
|
6531
|
+
if (p.status !== "shipped" && p.status !== "gap-inflight") continue;
|
|
6532
|
+
if (!known.has(p.name)) {
|
|
6533
|
+
manifest.warnings.push(
|
|
6534
|
+
`'${p.name}' is declared shipped in the handoff but is not in the registry \u2014 skipped`
|
|
6535
|
+
);
|
|
6536
|
+
continue;
|
|
6537
|
+
}
|
|
6538
|
+
const ok = tryAddPrimitive(p, sandboxDir, options.json ?? false);
|
|
6539
|
+
if (ok) shipped.push(p.name);
|
|
6540
|
+
}
|
|
6541
|
+
const gaps = manifest.primitives.filter((p) => p.status === "gap-new").map((p) => p.name);
|
|
6542
|
+
writeSandboxConfig(sandboxDir, manifest, port, {
|
|
6543
|
+
handoffPath,
|
|
6544
|
+
theme: options.theme,
|
|
6545
|
+
visorVersion: readCliVersion()
|
|
6546
|
+
});
|
|
6547
|
+
return {
|
|
6548
|
+
success: true,
|
|
6549
|
+
sandboxDir,
|
|
6550
|
+
port,
|
|
6551
|
+
primitives: { shipped, gaps },
|
|
6552
|
+
warnings: manifest.warnings
|
|
6553
|
+
};
|
|
6554
|
+
}
|
|
6555
|
+
function applyThemeIfPossible(sandboxDir, manifest, theme2, cwd, json) {
|
|
6556
|
+
const directPath = isAbsolute2(theme2) ? theme2 : resolve15(cwd, theme2);
|
|
6557
|
+
const candidates = [
|
|
6558
|
+
directPath,
|
|
6559
|
+
join21(cwd, "themes", `${theme2}.visor.yaml`),
|
|
6560
|
+
join21(cwd, "custom-themes", `${theme2}.visor.yaml`)
|
|
6561
|
+
];
|
|
6562
|
+
const yamlPath = candidates.find((p) => existsSync21(p));
|
|
6563
|
+
if (!yamlPath) {
|
|
6564
|
+
manifest.warnings.push(
|
|
6565
|
+
`Theme '${theme2}' not found in themes/ or custom-themes/ \u2014 sandbox uses placeholder globals.css. Run 'visor theme apply <path> --adapter nextjs -o app/globals.css' manually.`
|
|
6566
|
+
);
|
|
6567
|
+
if (!json) {
|
|
6568
|
+
logger.warn(`Theme '${theme2}' not found \u2014 leaving placeholder globals.css.`);
|
|
6569
|
+
}
|
|
6570
|
+
return;
|
|
6571
|
+
}
|
|
6572
|
+
const globalsOut = join21(sandboxDir, "app", "globals.css");
|
|
6573
|
+
try {
|
|
6574
|
+
themeApplyCommand(yamlPath, sandboxDir, {
|
|
6575
|
+
output: globalsOut,
|
|
6576
|
+
adapter: "nextjs",
|
|
6577
|
+
json: false
|
|
6578
|
+
});
|
|
6579
|
+
} catch (err) {
|
|
6580
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6581
|
+
manifest.warnings.push(`Theme apply failed: ${msg}`);
|
|
6582
|
+
if (!json) logger.warn(`Theme apply failed: ${msg}`);
|
|
6583
|
+
}
|
|
6584
|
+
}
|
|
6585
|
+
function tryAddPrimitive(primitive, sandboxDir, json) {
|
|
6586
|
+
try {
|
|
6587
|
+
addCommand([primitive.name], sandboxDir, {
|
|
6588
|
+
overwrite: true,
|
|
6589
|
+
target: "react",
|
|
6590
|
+
json: false
|
|
6591
|
+
});
|
|
6592
|
+
return true;
|
|
6593
|
+
} catch (err) {
|
|
6594
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6595
|
+
if (!json) {
|
|
6596
|
+
logger.warn(`Skipped '${primitive.name}': ${msg}`);
|
|
6597
|
+
}
|
|
6598
|
+
return false;
|
|
6599
|
+
}
|
|
6600
|
+
}
|
|
6601
|
+
function runNpmInstall(sandboxDir, json) {
|
|
6602
|
+
if (!json) logger.info("Installing sandbox dependencies...");
|
|
6603
|
+
const result = childProcess2.spawnSync("npm", ["install", "--no-audit", "--no-fund"], {
|
|
6604
|
+
cwd: sandboxDir,
|
|
6605
|
+
stdio: json ? "ignore" : "inherit"
|
|
6606
|
+
});
|
|
6607
|
+
if (result.error) {
|
|
6608
|
+
throw new Error(`npm install failed to start: ${result.error.message}`);
|
|
6609
|
+
}
|
|
6610
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
6611
|
+
throw new Error(`npm install exited with code ${result.status}`);
|
|
6612
|
+
}
|
|
6613
|
+
}
|
|
6614
|
+
function loadKnownPrimitives() {
|
|
6615
|
+
try {
|
|
6616
|
+
const reg = loadRegistry();
|
|
6617
|
+
const items = filterItemsByTarget(reg.items, "react");
|
|
6618
|
+
return new Set(items.map((i) => i.name));
|
|
6619
|
+
} catch {
|
|
6620
|
+
return /* @__PURE__ */ new Set();
|
|
6621
|
+
}
|
|
6622
|
+
}
|
|
6623
|
+
function readCliVersion() {
|
|
6624
|
+
try {
|
|
6625
|
+
const here = dirname9(fileURLToPath3(import.meta.url));
|
|
6626
|
+
for (let i = 0; i < 6; i++) {
|
|
6627
|
+
const segments = new Array(i).fill("..");
|
|
6628
|
+
const candidate = join21(here, ...segments, "package.json");
|
|
6629
|
+
try {
|
|
6630
|
+
const pkg2 = JSON.parse(readFileSync23(candidate, "utf-8"));
|
|
6631
|
+
if (pkg2.name === "@loworbitstudio/visor" && pkg2.version) return pkg2.version;
|
|
6632
|
+
} catch {
|
|
6633
|
+
}
|
|
6634
|
+
}
|
|
6635
|
+
} catch {
|
|
6636
|
+
}
|
|
6637
|
+
return "0.0.0-dev";
|
|
6638
|
+
}
|
|
6639
|
+
|
|
6640
|
+
// src/commands/sandbox/dev.ts
|
|
6641
|
+
import { existsSync as existsSync22, readFileSync as readFileSync24 } from "fs";
|
|
6642
|
+
import { join as join22 } from "path";
|
|
6643
|
+
import * as childProcess3 from "child_process";
|
|
6644
|
+
function sandboxDevCommand(cwd, options) {
|
|
6645
|
+
const json = options.json ?? false;
|
|
6646
|
+
const sandboxDir = join22(cwd, ".lo", "sandbox", options.name);
|
|
6647
|
+
const configPath = join22(sandboxDir, "sandbox.json");
|
|
6648
|
+
if (!existsSync22(configPath)) {
|
|
6649
|
+
fail(
|
|
6650
|
+
json,
|
|
6651
|
+
`Sandbox '${options.name}' not found at ${sandboxDir}. Run 'visor sandbox init ${options.name} --handoff ... --theme ...' first.`
|
|
6652
|
+
);
|
|
6653
|
+
return;
|
|
6654
|
+
}
|
|
6655
|
+
let config;
|
|
6656
|
+
try {
|
|
6657
|
+
config = JSON.parse(readFileSync24(configPath, "utf-8"));
|
|
6658
|
+
} catch (err) {
|
|
6659
|
+
fail(json, `Invalid sandbox.json at ${configPath}: ${err.message}`);
|
|
6660
|
+
return;
|
|
6661
|
+
}
|
|
6662
|
+
const baseUrl = `http://localhost:${config.port}`;
|
|
6663
|
+
const routes = [
|
|
6664
|
+
"/",
|
|
6665
|
+
...config.primitives.map((p) => `/primitives/${p.name}`),
|
|
6666
|
+
...config.screens.map((s) => `/screens/${s.name}`)
|
|
6667
|
+
];
|
|
6668
|
+
if (json) {
|
|
6669
|
+
console.log(
|
|
6670
|
+
JSON.stringify({ success: true, baseUrl, port: config.port, routes }, null, 2)
|
|
6671
|
+
);
|
|
6672
|
+
} else {
|
|
6673
|
+
logger.info(`Sandbox: ${config.pattern}`);
|
|
6674
|
+
logger.info(`Dev server on port ${config.port}`);
|
|
6675
|
+
logger.info(`Base URL: ${baseUrl}`);
|
|
6676
|
+
logger.blank();
|
|
6677
|
+
logger.info("Routes:");
|
|
6678
|
+
for (const r of routes) logger.item(`${baseUrl}${r}`);
|
|
6679
|
+
logger.blank();
|
|
6680
|
+
}
|
|
6681
|
+
spawnNextDev(sandboxDir, config.port, json);
|
|
6682
|
+
}
|
|
6683
|
+
function spawnNextDev(sandboxDir, port, json) {
|
|
6684
|
+
const child = childProcess3.spawn(
|
|
6685
|
+
"npx",
|
|
6686
|
+
["--no-install", "next", "dev", "--port", String(port)],
|
|
6687
|
+
{
|
|
6688
|
+
cwd: sandboxDir,
|
|
6689
|
+
stdio: json ? "ignore" : "inherit"
|
|
6690
|
+
}
|
|
6691
|
+
);
|
|
6692
|
+
child.on("exit", (code) => {
|
|
6693
|
+
if (typeof code === "number" && code !== 0 && !json) {
|
|
6694
|
+
logger.warn(`next dev exited with code ${code}`);
|
|
6695
|
+
}
|
|
6696
|
+
});
|
|
6697
|
+
const stop = () => {
|
|
6698
|
+
if (!child.killed) child.kill("SIGINT");
|
|
6699
|
+
};
|
|
6700
|
+
process.on("SIGINT", stop);
|
|
6701
|
+
process.on("SIGTERM", stop);
|
|
6702
|
+
}
|
|
6703
|
+
function fail(json, message) {
|
|
6704
|
+
if (json) {
|
|
6705
|
+
console.log(JSON.stringify({ success: false, error: message }, null, 2));
|
|
6706
|
+
} else {
|
|
6707
|
+
logger.error(message);
|
|
6708
|
+
}
|
|
6709
|
+
process.exit(1);
|
|
6710
|
+
}
|
|
6711
|
+
|
|
6712
|
+
// src/commands/sandbox/approve.ts
|
|
6713
|
+
import { existsSync as existsSync23, readFileSync as readFileSync25, writeFileSync as writeFileSync13, readdirSync as readdirSync13 } from "fs";
|
|
6714
|
+
import { join as join23 } from "path";
|
|
6715
|
+
import * as childProcess4 from "child_process";
|
|
6716
|
+
function sandboxApproveCommand(cwd, options) {
|
|
6717
|
+
const json = options.json ?? false;
|
|
6718
|
+
const sandboxDir = join23(cwd, ".lo", "sandbox", options.name);
|
|
6719
|
+
const configPath = join23(sandboxDir, "sandbox.json");
|
|
6720
|
+
if (!existsSync23(configPath)) {
|
|
6721
|
+
fail2(
|
|
6722
|
+
json,
|
|
6723
|
+
`Sandbox '${options.name}' not found at ${sandboxDir}. Run 'visor sandbox init' first.`
|
|
6724
|
+
);
|
|
6725
|
+
return;
|
|
6726
|
+
}
|
|
6727
|
+
const config = JSON.parse(readFileSync25(configPath, "utf-8"));
|
|
6728
|
+
const captureScriptPath = join23(sandboxDir, "playwright.capture.mjs");
|
|
6729
|
+
writeFileSync13(captureScriptPath, captureScriptTemplate(), "utf-8");
|
|
6730
|
+
ensurePlaywrightInstalled(sandboxDir, json);
|
|
6731
|
+
const args = ["--no-install", "node", "playwright.capture.mjs"];
|
|
6732
|
+
if (options.diff) args.push("--diff");
|
|
6733
|
+
const result = childProcess4.spawnSync("npx", args, {
|
|
6734
|
+
cwd: sandboxDir,
|
|
6735
|
+
stdio: "pipe",
|
|
6736
|
+
env: { ...process.env, SANDBOX_PORT: String(config.port) },
|
|
6737
|
+
encoding: "utf-8"
|
|
6738
|
+
});
|
|
6739
|
+
if (result.error) {
|
|
6740
|
+
fail2(json, `Capture failed to start: ${result.error.message}`);
|
|
6741
|
+
return;
|
|
6742
|
+
}
|
|
6743
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
6744
|
+
fail2(json, `Capture script exited with code ${result.status}
|
|
6745
|
+
${result.stderr}`);
|
|
6746
|
+
return;
|
|
6747
|
+
}
|
|
6748
|
+
const approvedDir = join23(sandboxDir, "captures", "approved");
|
|
6749
|
+
const diffsDir = join23(sandboxDir, "captures", "diffs");
|
|
6750
|
+
const approvedFiles = safeListPngs(approvedDir);
|
|
6751
|
+
const diffFiles = options.diff ? safeListPngs(diffsDir) : [];
|
|
6752
|
+
if (json) {
|
|
6753
|
+
console.log(
|
|
6754
|
+
JSON.stringify(
|
|
6755
|
+
{
|
|
6756
|
+
success: true,
|
|
6757
|
+
mode: options.diff ? "diff" : "approve",
|
|
6758
|
+
approvedDir,
|
|
6759
|
+
diffsDir: options.diff ? diffsDir : null,
|
|
6760
|
+
approved: approvedFiles,
|
|
6761
|
+
diffs: diffFiles,
|
|
6762
|
+
captureOutput: tryParseJson(result.stdout)
|
|
6763
|
+
},
|
|
6764
|
+
null,
|
|
6765
|
+
2
|
|
6766
|
+
)
|
|
6767
|
+
);
|
|
6768
|
+
} else {
|
|
6769
|
+
logger.success(
|
|
6770
|
+
options.diff ? `Pixel-diff complete. ${diffFiles.length} diff PNGs in ${diffsDir}` : `Captured ${approvedFiles.length} PNGs in ${approvedDir}`
|
|
6771
|
+
);
|
|
6772
|
+
if (options.diff && diffFiles.length > 0) {
|
|
6773
|
+
logger.blank();
|
|
6774
|
+
logger.info("Changed routes:");
|
|
6775
|
+
for (const f of diffFiles) logger.item(f);
|
|
6776
|
+
}
|
|
6777
|
+
}
|
|
6778
|
+
}
|
|
6779
|
+
function ensurePlaywrightInstalled(sandboxDir, json) {
|
|
6780
|
+
const markerPath = join23(sandboxDir, ".playwright-installed");
|
|
6781
|
+
if (existsSync23(markerPath)) return;
|
|
6782
|
+
if (!json) logger.info("Installing Playwright Chromium (one-time)...");
|
|
6783
|
+
const result = childProcess4.spawnSync("npx", ["--no-install", "playwright", "install", "chromium"], {
|
|
6784
|
+
cwd: sandboxDir,
|
|
6785
|
+
stdio: json ? "ignore" : "inherit"
|
|
6786
|
+
});
|
|
6787
|
+
if (result.error) {
|
|
6788
|
+
throw new Error(`playwright install failed to start: ${result.error.message}`);
|
|
6789
|
+
}
|
|
6790
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
6791
|
+
throw new Error(`playwright install exited with code ${result.status}`);
|
|
6792
|
+
}
|
|
6793
|
+
writeFileSync13(markerPath, (/* @__PURE__ */ new Date()).toISOString(), "utf-8");
|
|
6794
|
+
}
|
|
6795
|
+
function safeListPngs(dir) {
|
|
6796
|
+
if (!existsSync23(dir)) return [];
|
|
6797
|
+
return readdirSync13(dir).filter((f) => f.endsWith(".png")).map((f) => join23(dir, f));
|
|
6798
|
+
}
|
|
6799
|
+
function tryParseJson(s) {
|
|
6800
|
+
try {
|
|
6801
|
+
return JSON.parse(s);
|
|
6802
|
+
} catch {
|
|
6803
|
+
return s.trim();
|
|
6804
|
+
}
|
|
6805
|
+
}
|
|
6806
|
+
function fail2(json, message) {
|
|
6807
|
+
if (json) {
|
|
6808
|
+
console.log(JSON.stringify({ success: false, error: message }, null, 2));
|
|
6809
|
+
} else {
|
|
6810
|
+
logger.error(message);
|
|
6811
|
+
}
|
|
6812
|
+
process.exit(1);
|
|
6813
|
+
}
|
|
6814
|
+
|
|
5725
6815
|
// src/index.ts
|
|
5726
|
-
var __dirname2 =
|
|
6816
|
+
var __dirname2 = dirname10(fileURLToPath4(import.meta.url));
|
|
5727
6817
|
var pkg = JSON.parse(
|
|
5728
|
-
|
|
6818
|
+
readFileSync26(join24(__dirname2, "..", "package.json"), "utf-8")
|
|
5729
6819
|
);
|
|
5730
6820
|
var program = new Command2();
|
|
5731
6821
|
program.name("visor").description("CLI for the Visor design system").version(pkg.version);
|
|
@@ -5877,4 +6967,18 @@ migrate.command("token-substitution").description(
|
|
|
5877
6967
|
});
|
|
5878
6968
|
}
|
|
5879
6969
|
);
|
|
6970
|
+
var sandbox = program.command("sandbox").description("Scaffold and iterate on a Next.js sandbox for new primitives");
|
|
6971
|
+
sandbox.command("init").description(
|
|
6972
|
+
"Scaffold a Next.js sandbox at .lo/sandbox/<name>/ from a design-handoff manifest"
|
|
6973
|
+
).argument("<name>", "sandbox name (used as directory and pattern slug)").requiredOption("--handoff <path>", "path to design-handoff.md manifest").requiredOption("--theme <theme>", "theme slug (e.g. 'space') or path to .visor.yaml").option("--overwrite", "replace an existing sandbox at this name", false).option("--skip-install", "skip npm install (test fixture mode)", false).option("--json", "output structured JSON (for AI agents)").action(
|
|
6974
|
+
async (name, options) => {
|
|
6975
|
+
await sandboxInitCommand(name, process.cwd(), options);
|
|
6976
|
+
}
|
|
6977
|
+
);
|
|
6978
|
+
sandbox.command("dev").description("Boot the Next.js dev server for a sandbox on its allocated port").requiredOption("--name <name>", "sandbox name (created via 'sandbox init')").option("--json", "output structured JSON (for AI agents)").action((options) => {
|
|
6979
|
+
sandboxDevCommand(process.cwd(), options);
|
|
6980
|
+
});
|
|
6981
|
+
sandbox.command("approve").description("Capture Playwright screenshots of every sandbox route as the visual spec").requiredOption("--name <name>", "sandbox name (created via 'sandbox init')").option("--diff", "pixel-diff against the prior approved baseline", false).option("--json", "output structured JSON (for AI agents)").action((options) => {
|
|
6982
|
+
sandboxApproveCommand(process.cwd(), options);
|
|
6983
|
+
});
|
|
5880
6984
|
program.parse();
|