@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.
- package/README.md +40 -0
- package/dist-lib/pygmalion.js +561 -544
- package/node/design-session.mjs +15 -1
- package/node/dev-mirror.mjs +142 -5
- package/node/preview-artifact-plugin.mjs +0 -0
- package/node/vite.mjs +4 -0
- package/package.json +1 -1
- package/types.d.ts +12 -1
- package/vite.d.ts +12 -0
package/node/design-session.mjs
CHANGED
|
@@ -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
|
-
[
|
|
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: {
|
package/node/dev-mirror.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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';
|