@pygmalionjs/pygmalion 0.2.14 → 0.2.16

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.
@@ -7,6 +7,7 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { promisify } from 'node:util';
10
+ import { normalizeViteMode } from './dev-mirror.mjs';
10
11
  import { writeStyleEdit, writeTextEdit } from './inspect-plugin.mjs';
11
12
  import { createUnifiedDiff } from './source-diff.mjs';
12
13
  import { writeSourceOperations } from './source-operations.mjs';
@@ -289,6 +290,9 @@ export function pygmalionDesignSessionPlugin(options) {
289
290
  options.previewConfig ?? fileURLToPath(new URL('./dev-view.vite.mjs', import.meta.url)),
290
291
  );
291
292
  const viteConfig = path.resolve(options.viteConfig ?? path.join(mirrorAppRoot, 'vite.config.ts'));
293
+ // Session previews are separate Vite processes too, so they need the same
294
+ // host-declared mode as the mirror or a session would render a different app.
295
+ const previewMode = normalizeViteMode(options.previewMode);
292
296
  const sourceDirectory = (options.sourceDirectory ?? 'src').replace(/^\.?\//, '').replace(/\/$/, '') || '.';
293
297
  const dependencies = options.dependencies ?? {};
294
298
  const inspect = options.inspect ?? {};
@@ -503,7 +507,17 @@ export function pygmalionDesignSessionPlugin(options) {
503
507
 
504
508
  const child = spawn(
505
509
  process.execPath,
506
- [viteBin, '--config', wrapperConfig, '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
510
+ [
511
+ viteBin,
512
+ '--config',
513
+ wrapperConfig,
514
+ ...(previewMode ? ['--mode', previewMode] : []),
515
+ '--host',
516
+ '127.0.0.1',
517
+ '--port',
518
+ String(port),
519
+ '--strictPort',
520
+ ],
507
521
  {
508
522
  cwd: sessionAppRoot,
509
523
  env: {
@@ -114,12 +114,50 @@ function escapeRegExp(value) {
114
114
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
115
115
  }
116
116
 
117
+ /**
118
+ * Renders the current working tree, uncommitted changes included, instead of a
119
+ * committed ref. Resolved to a throwaway commit at sync time.
120
+ */
121
+ export const PYGMALION_WORKTREE_SOURCE_REF = 'worktree';
122
+
117
123
  function normalizeLocalSourceRef(value) {
118
124
  if (value == null) return null;
119
125
  if (typeof value !== 'string' || value.trim() === '') {
120
126
  throw new Error('Pygmalion source.ref must be a non-empty string');
121
127
  }
122
- return value.trim();
128
+ const ref = value.trim();
129
+ // The ref reaches git as an argument, so a leading dash would be read as a
130
+ // flag and whitespace or control characters would split it.
131
+ if (ref.startsWith('-')) {
132
+ throw new Error('Pygmalion source.ref must not start with "-"');
133
+ }
134
+ if (/[\s\u0000-\u001f\u007f]/u.test(ref)) {
135
+ throw new Error(
136
+ 'Pygmalion source.ref must not contain whitespace or control characters',
137
+ );
138
+ }
139
+ if (ref.length > 256) {
140
+ throw new Error('Pygmalion source.ref is too long');
141
+ }
142
+ return ref;
143
+ }
144
+
145
+ /**
146
+ * The mode becomes an argv entry for the spawned preview Vite, so it is
147
+ * restricted to the identifier shape Vite mode names actually use. Anything
148
+ * else could smuggle a second flag into that command line.
149
+ */
150
+ export function normalizeViteMode(value) {
151
+ if (value == null) return null;
152
+ if (typeof value !== 'string' || !/^[A-Za-z0-9._-]+$/.test(value)) {
153
+ throw new Error(
154
+ 'Pygmalion preview.mode must be a Vite mode name ([A-Za-z0-9._-]+)',
155
+ );
156
+ }
157
+ if (value.startsWith('-')) {
158
+ throw new Error('Pygmalion preview.mode must not start with "-"');
159
+ }
160
+ return value;
123
161
  }
124
162
 
125
163
  function normalizeManagedOutputPaths(value) {
@@ -155,6 +193,38 @@ function normalizeManagedOutputPaths(value) {
155
193
  ];
156
194
  }
157
195
 
196
+ const MAX_REFRESH_BODY_BYTES = 4096;
197
+
198
+ /**
199
+ * Reads an optional `{ ref }` from a refresh request. Returns undefined when the
200
+ * body is empty so a plain refresh keeps re-syncing the ref already in use.
201
+ */
202
+ async function readRefFromRequest(req) {
203
+ const chunks = [];
204
+ let bytes = 0;
205
+ for await (const chunk of req) {
206
+ bytes += chunk.length;
207
+ if (bytes > MAX_REFRESH_BODY_BYTES) {
208
+ throw new Error('refresh body is too large');
209
+ }
210
+ chunks.push(chunk);
211
+ }
212
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
213
+ if (!raw) return undefined;
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(raw);
217
+ } catch {
218
+ throw new Error('refresh body must be JSON');
219
+ }
220
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
221
+ throw new Error('refresh body must be a JSON object');
222
+ }
223
+ if (!('ref' in parsed) || parsed.ref == null) return undefined;
224
+ // normalizeLocalSourceRef enforces the git-argument safety rules.
225
+ return normalizeLocalSourceRef(parsed.ref);
226
+ }
227
+
158
228
  async function restoreManagedOutputs(mirrorRoot, managedPaths) {
159
229
  if (managedPaths.length === 0) return;
160
230
  const trackedRaw = await git(mirrorRoot, 'ls-files', '-z', '--', ...managedPaths);
@@ -173,13 +243,46 @@ async function restoreManagedOutputs(mirrorRoot, managedPaths) {
173
243
  await git(mirrorRoot, 'clean', '-fd', '--', ...managedPaths);
174
244
  }
175
245
 
246
+ /**
247
+ * Turns the current working tree into a commit the mirror can check out, so
248
+ * uncommitted edits can be previewed without polluting any branch.
249
+ *
250
+ * `git stash create` writes a dangling commit and leaves the working tree and
251
+ * the index untouched. It records tracked changes only — untracked files stay
252
+ * out, which the caller reports as a warning.
253
+ */
254
+ async function resolveWorktreeSourceCommit(repoRoot, onWarning) {
255
+ const created = await git(repoRoot, 'stash', 'create');
256
+ if (!created) {
257
+ // Nothing uncommitted — HEAD already is the working tree.
258
+ return git(repoRoot, 'rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}');
259
+ }
260
+ const untracked = await git(
261
+ repoRoot,
262
+ 'ls-files',
263
+ '--others',
264
+ '--exclude-standard',
265
+ );
266
+ if (untracked) {
267
+ const count = untracked.split('\n').filter(Boolean).length;
268
+ onWarning?.(
269
+ `working tree preview excludes ${count} untracked file(s); commit or stage them to include`,
270
+ );
271
+ }
272
+ return created;
273
+ }
274
+
176
275
  export async function syncDevMirrorWorktree(options) {
177
276
  const repoRoot = path.resolve(options.repoRoot);
178
277
  const mirrorRoot = path.resolve(options.mirrorRoot);
179
278
  const branch = options.branch ?? 'dev';
180
279
  const remote = options.remote ?? 'origin';
181
280
  const remoteRef = `${remote}/${branch}`;
182
- const localRef = normalizeLocalSourceRef(options.ref);
281
+ const requestedRef = normalizeLocalSourceRef(options.ref);
282
+ const worktreeMode = requestedRef === PYGMALION_WORKTREE_SOURCE_REF;
283
+ const localRef = worktreeMode
284
+ ? await resolveWorktreeSourceCommit(repoRoot, options.onWarning)
285
+ : requestedRef;
183
286
  const managedPaths = normalizeManagedOutputPaths(options.managedPaths);
184
287
  let warning = null;
185
288
  let switchTarget = null;
@@ -266,7 +369,8 @@ export function pygmalionDevMirrorPlugin(options) {
266
369
  const repoRoot = path.resolve(options.projectRoot ?? path.resolve(editorRoot, '..'));
267
370
  const branch = options.branch ?? 'dev';
268
371
  const remote = options.remote ?? 'origin';
269
- const ref = options.ref;
372
+ // Mutable so the editor can repoint the mirror without a server restart.
373
+ let ref = normalizeLocalSourceRef(options.ref);
270
374
  const appDirectory = options.appDirectory ?? path.relative(repoRoot, editorRoot);
271
375
  const mirrorRoot = path.resolve(
272
376
  process.env.PYGMALION_DEV_WORKTREE ||
@@ -278,6 +382,10 @@ export function pygmalionDevMirrorPlugin(options) {
278
382
  options.previewConfig ?? fileURLToPath(new URL('./dev-view.vite.mjs', import.meta.url)),
279
383
  );
280
384
  const viteConfig = path.resolve(options.viteConfig ?? path.join(mirrorAppRoot, 'vite.config.ts'));
385
+ // The mirror is a separate Vite process, so the editor's own `--mode` never
386
+ // reaches it. Hosts whose captured screens need a specific env file (a feature
387
+ // flag a screen depends on, a mock profile) declare that mode here.
388
+ const previewMode = normalizeViteMode(options.previewMode);
281
389
  const inventory = options.inventory;
282
390
  const inventoryOutputs = normalizeManagedOutputPaths(inventory?.outputs);
283
391
  const managedPaths = normalizeManagedOutputPaths(
@@ -300,6 +408,7 @@ export function pygmalionDevMirrorPlugin(options) {
300
408
  state: 'idle',
301
409
  commit: null,
302
410
  shortCommit: null,
411
+ sourceRef: ref,
303
412
  syncedAt: null,
304
413
  updated: false,
305
414
  warning: null,
@@ -450,6 +559,7 @@ export function pygmalionDevMirrorPlugin(options) {
450
559
  viteBin,
451
560
  '--config',
452
561
  wrapperConfig,
562
+ ...(previewMode ? ['--mode', previewMode] : []),
453
563
  '--host',
454
564
  '127.0.0.1',
455
565
  '--port',
@@ -487,7 +597,14 @@ export function pygmalionDevMirrorPlugin(options) {
487
597
  await waitUntilReady(previewPort, mirrorAppRoot, prefix);
488
598
  };
489
599
 
490
- const syncMirror = async () => {
600
+ const syncMirror = async (nextRef) => {
601
+ if (nextRef !== undefined) {
602
+ const normalized = normalizeLocalSourceRef(nextRef);
603
+ // A ref change invalidates the mirror checkout, so it must not land while
604
+ // a sync is already materializing the previous one.
605
+ if (syncPromise) await syncPromise.catch(() => undefined);
606
+ ref = normalized;
607
+ }
491
608
  if (syncPromise) return syncPromise;
492
609
  syncPromise = (async () => {
493
610
  status = { ...status, state: 'syncing', error: null, warning: null };
@@ -516,6 +633,7 @@ export function pygmalionDevMirrorPlugin(options) {
516
633
  state: 'ready',
517
634
  commit,
518
635
  shortCommit,
636
+ sourceRef: ref,
519
637
  syncedAt: new Date().toISOString(),
520
638
  updated: previous !== commit,
521
639
  warning,
@@ -556,7 +674,26 @@ export function pygmalionDevMirrorPlugin(options) {
556
674
  return;
557
675
  }
558
676
  if (url.pathname === `${control}/refresh` && req.method === 'POST') {
559
- const nextStatus = await syncMirror();
677
+ let requestedRef;
678
+ try {
679
+ requestedRef = await readRefFromRequest(req);
680
+ } catch (error) {
681
+ json(res, 400, {
682
+ ok: false,
683
+ error: error instanceof Error ? error.message : String(error),
684
+ });
685
+ return;
686
+ }
687
+ let nextStatus;
688
+ try {
689
+ nextStatus = await syncMirror(requestedRef);
690
+ } catch (error) {
691
+ json(res, 400, {
692
+ ok: false,
693
+ error: error instanceof Error ? error.message : String(error),
694
+ });
695
+ return;
696
+ }
560
697
  json(res, nextStatus.state === 'ready' ? 200 : 409, nextStatus);
561
698
  return;
562
699
  }
Binary file
package/node/vite.mjs CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import {
3
3
  PYGMALION_DEV_CONTROL,
4
4
  PYGMALION_DEV_PREFIX,
5
+ PYGMALION_WORKTREE_SOURCE_REF,
5
6
  pygmalionDevMirrorPlugin,
6
7
  } from './dev-mirror.mjs';
7
8
  import {
@@ -267,6 +268,7 @@ export function createPygmalionVitePlugins(config) {
267
268
  previewConfig: project.preview.configFile,
268
269
  viteConfig: project.preview.viteConfig,
269
270
  viteBin: project.preview.viteBin,
271
+ previewMode: project.preview.mode,
270
272
  previewPort: project.mirror.previewPort ?? project.preview.mirrorPort,
271
273
  prefix: normalizeEndpoint(
272
274
  project.mirror.prefix,
@@ -298,6 +300,7 @@ export function createPygmalionVitePlugins(config) {
298
300
  previewConfig: project.preview.configFile,
299
301
  viteConfig: project.preview.viteConfig,
300
302
  viteBin: project.preview.viteBin,
303
+ previewMode: project.preview.mode,
301
304
  previewPort:
302
305
  project.sessions.previewPort ?? project.preview.sessionPort,
303
306
  worktreesRoot: project.sessions.worktreesRoot,
@@ -321,6 +324,7 @@ export function createPygmalionVitePlugins(config) {
321
324
  export {
322
325
  PYGMALION_DEV_CONTROL,
323
326
  PYGMALION_DEV_PREFIX,
327
+ PYGMALION_WORKTREE_SOURCE_REF,
324
328
  PYGMALION_INSPECT_CONTROL,
325
329
  PYGMALION_SESSION_CONTROL,
326
330
  PYGMALION_SESSION_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
package/types.d.ts CHANGED
@@ -1455,6 +1455,8 @@ export interface PygmalionDevMirrorStatus {
1455
1455
  state: 'idle' | 'syncing' | 'ready' | 'error';
1456
1456
  commit: string | null;
1457
1457
  shortCommit: string | null;
1458
+ /** Git ref the mirror is tracking, or null when it follows the source branch. */
1459
+ sourceRef: string | null;
1458
1460
  syncedAt: string | null;
1459
1461
  updated: boolean;
1460
1462
  warning: string | null;
@@ -1555,7 +1557,16 @@ export interface PygmalionProjectRuntime {
1555
1557
  * namespaces from it instead of parsing previewRevision themselves.
1556
1558
  */
1557
1559
  previewSourceRevision: string;
1558
- refresh(manual?: boolean): Promise<void>;
1560
+ refresh(manual?: boolean, sourceRef?: string): Promise<void>;
1561
+ /**
1562
+ * Repoints the mirror at another git ref without restarting the dev server.
1563
+ * Pass PYGMALION_WORKTREE_SOURCE_REF to preview uncommitted work.
1564
+ * The artifact cache is keyed by source SHA, so a switch invalidates it.
1565
+ */
1566
+ switchSource(
1567
+ sourceRef: string,
1568
+ options?: { discardEdits?: boolean },
1569
+ ): Promise<void>;
1559
1570
  previewVisual(payload: InspectApplyPayload): Promise<InspectPreviewResult>;
1560
1571
  inspectImpact(componentFiles: string[]): Promise<InspectImpactResult>;
1561
1572
  applyVisual(payload: InspectApplyPayload): Promise<InspectApplyResult>;
package/vite.d.ts CHANGED
@@ -60,6 +60,15 @@ export interface PygmalionPreviewConfig {
60
60
  configFile?: string;
61
61
  viteConfig?: string;
62
62
  viteBin?: string;
63
+ /**
64
+ * Vite mode for the mirror and session preview servers.
65
+ *
66
+ * Those run as separate Vite processes, so the editor's own `--mode` never
67
+ * reaches the previewed application. Set this when a captured screen depends
68
+ * on a specific env file — for example a feature flag that has to be off for
69
+ * the screen to exist at all. Defaults to Vite's own default mode.
70
+ */
71
+ mode?: string;
63
72
  mirrorPort?: number;
64
73
  sessionPort?: number;
65
74
  artifactFile?: string;
@@ -548,3 +557,6 @@ export declare function findAffectedSourceFiles(
548
557
  componentFiles: string[],
549
558
  options?: PygmalionSourceGraphOptions,
550
559
  ): Promise<string[]>;
560
+
561
+ /** source.ref value that previews the current working tree, uncommitted changes included. */
562
+ export declare const PYGMALION_WORKTREE_SOURCE_REF: 'worktree';