@pygmalionjs/pygmalion 0.2.15 → 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.
@@ -114,12 +114,32 @@ 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;
123
143
  }
124
144
 
125
145
  /**
@@ -173,6 +193,38 @@ function normalizeManagedOutputPaths(value) {
173
193
  ];
174
194
  }
175
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
+
176
228
  async function restoreManagedOutputs(mirrorRoot, managedPaths) {
177
229
  if (managedPaths.length === 0) return;
178
230
  const trackedRaw = await git(mirrorRoot, 'ls-files', '-z', '--', ...managedPaths);
@@ -191,13 +243,46 @@ async function restoreManagedOutputs(mirrorRoot, managedPaths) {
191
243
  await git(mirrorRoot, 'clean', '-fd', '--', ...managedPaths);
192
244
  }
193
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
+
194
275
  export async function syncDevMirrorWorktree(options) {
195
276
  const repoRoot = path.resolve(options.repoRoot);
196
277
  const mirrorRoot = path.resolve(options.mirrorRoot);
197
278
  const branch = options.branch ?? 'dev';
198
279
  const remote = options.remote ?? 'origin';
199
280
  const remoteRef = `${remote}/${branch}`;
200
- 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;
201
286
  const managedPaths = normalizeManagedOutputPaths(options.managedPaths);
202
287
  let warning = null;
203
288
  let switchTarget = null;
@@ -284,7 +369,8 @@ export function pygmalionDevMirrorPlugin(options) {
284
369
  const repoRoot = path.resolve(options.projectRoot ?? path.resolve(editorRoot, '..'));
285
370
  const branch = options.branch ?? 'dev';
286
371
  const remote = options.remote ?? 'origin';
287
- const ref = options.ref;
372
+ // Mutable so the editor can repoint the mirror without a server restart.
373
+ let ref = normalizeLocalSourceRef(options.ref);
288
374
  const appDirectory = options.appDirectory ?? path.relative(repoRoot, editorRoot);
289
375
  const mirrorRoot = path.resolve(
290
376
  process.env.PYGMALION_DEV_WORKTREE ||
@@ -322,6 +408,7 @@ export function pygmalionDevMirrorPlugin(options) {
322
408
  state: 'idle',
323
409
  commit: null,
324
410
  shortCommit: null,
411
+ sourceRef: ref,
325
412
  syncedAt: null,
326
413
  updated: false,
327
414
  warning: null,
@@ -510,7 +597,14 @@ export function pygmalionDevMirrorPlugin(options) {
510
597
  await waitUntilReady(previewPort, mirrorAppRoot, prefix);
511
598
  };
512
599
 
513
- 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
+ }
514
608
  if (syncPromise) return syncPromise;
515
609
  syncPromise = (async () => {
516
610
  status = { ...status, state: 'syncing', error: null, warning: null };
@@ -539,6 +633,7 @@ export function pygmalionDevMirrorPlugin(options) {
539
633
  state: 'ready',
540
634
  commit,
541
635
  shortCommit,
636
+ sourceRef: ref,
542
637
  syncedAt: new Date().toISOString(),
543
638
  updated: previous !== commit,
544
639
  warning,
@@ -579,7 +674,26 @@ export function pygmalionDevMirrorPlugin(options) {
579
674
  return;
580
675
  }
581
676
  if (url.pathname === `${control}/refresh` && req.method === 'POST') {
582
- 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
+ }
583
697
  json(res, nextStatus.state === 'ready' ? 200 : 409, nextStatus);
584
698
  return;
585
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 {
@@ -323,6 +324,7 @@ export function createPygmalionVitePlugins(config) {
323
324
  export {
324
325
  PYGMALION_DEV_CONTROL,
325
326
  PYGMALION_DEV_PREFIX,
327
+ PYGMALION_WORKTREE_SOURCE_REF,
326
328
  PYGMALION_INSPECT_CONTROL,
327
329
  PYGMALION_SESSION_CONTROL,
328
330
  PYGMALION_SESSION_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.2.15",
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
@@ -557,3 +557,6 @@ export declare function findAffectedSourceFiles(
557
557
  componentFiles: string[],
558
558
  options?: PygmalionSourceGraphOptions,
559
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';