@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.
- package/README.md +28 -0
- package/dist-lib/pygmalion.js +561 -544
- package/node/dev-mirror.mjs +119 -5
- package/node/preview-artifact-plugin.mjs +0 -0
- package/node/vite.mjs +2 -0
- package/package.json +1 -1
- package/types.d.ts +12 -1
- package/vite.d.ts +3 -0
package/node/dev-mirror.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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';
|