@pygmalionjs/pygmalion 0.2.15 → 0.2.17
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 +48 -0
- package/dist-lib/pygmalion.js +932 -840
- package/node/dev-mirror.mjs +163 -5
- package/node/preview-artifact-plugin.mjs +0 -0
- package/node/vite.mjs +2 -0
- package/package.json +1 -1
- package/types.d.ts +49 -1
- package/vite.d.ts +3 -0
package/node/dev-mirror.mjs
CHANGED
|
@@ -114,12 +114,60 @@ function escapeRegExp(value) {
|
|
|
114
114
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Revisions the mirror can be repointed at: local branches first, then remote
|
|
119
|
+
* tracking branches. Listing them is what lets an editor offer a choice instead
|
|
120
|
+
* of asking for a ref string.
|
|
121
|
+
*/
|
|
122
|
+
export async function listSourceRefs(repoRoot, remote = 'origin') {
|
|
123
|
+
const format = '%(refname:short)%09%(objectname:short=8)%09%(refname)';
|
|
124
|
+
const read = async (...patterns) => {
|
|
125
|
+
const raw = await git(repoRoot, 'for-each-ref', `--format=${format}`, ...patterns);
|
|
126
|
+
if (!raw) return [];
|
|
127
|
+
return raw
|
|
128
|
+
.split('\n')
|
|
129
|
+
.map((line) => line.split('\t'))
|
|
130
|
+
.filter((parts) => parts.length === 3 && parts[0])
|
|
131
|
+
.map(([name, commit, fullName]) => ({ name, commit, fullName }));
|
|
132
|
+
};
|
|
133
|
+
const locals = (await read('refs/heads')).map((item) => ({ ...item, kind: 'local' }));
|
|
134
|
+
const remotes = (await read(`refs/remotes/${remote}`))
|
|
135
|
+
.filter((item) => !item.name.endsWith('/HEAD'))
|
|
136
|
+
.map((item) => ({ ...item, kind: 'remote' }));
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
return [...locals, ...remotes].filter((item) => {
|
|
139
|
+
if (seen.has(item.name)) return false;
|
|
140
|
+
seen.add(item.name);
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Renders the current working tree, uncommitted changes included, instead of a
|
|
147
|
+
* committed ref. Resolved to a throwaway commit at sync time.
|
|
148
|
+
*/
|
|
149
|
+
export const PYGMALION_WORKTREE_SOURCE_REF = 'worktree';
|
|
150
|
+
|
|
117
151
|
function normalizeLocalSourceRef(value) {
|
|
118
152
|
if (value == null) return null;
|
|
119
153
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
120
154
|
throw new Error('Pygmalion source.ref must be a non-empty string');
|
|
121
155
|
}
|
|
122
|
-
|
|
156
|
+
const ref = value.trim();
|
|
157
|
+
// The ref reaches git as an argument, so a leading dash would be read as a
|
|
158
|
+
// flag and whitespace or control characters would split it.
|
|
159
|
+
if (ref.startsWith('-')) {
|
|
160
|
+
throw new Error('Pygmalion source.ref must not start with "-"');
|
|
161
|
+
}
|
|
162
|
+
if (/[\s\u0000-\u001f\u007f]/u.test(ref)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
'Pygmalion source.ref must not contain whitespace or control characters',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (ref.length > 256) {
|
|
168
|
+
throw new Error('Pygmalion source.ref is too long');
|
|
169
|
+
}
|
|
170
|
+
return ref;
|
|
123
171
|
}
|
|
124
172
|
|
|
125
173
|
/**
|
|
@@ -173,6 +221,38 @@ function normalizeManagedOutputPaths(value) {
|
|
|
173
221
|
];
|
|
174
222
|
}
|
|
175
223
|
|
|
224
|
+
const MAX_REFRESH_BODY_BYTES = 4096;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Reads an optional `{ ref }` from a refresh request. Returns undefined when the
|
|
228
|
+
* body is empty so a plain refresh keeps re-syncing the ref already in use.
|
|
229
|
+
*/
|
|
230
|
+
async function readRefFromRequest(req) {
|
|
231
|
+
const chunks = [];
|
|
232
|
+
let bytes = 0;
|
|
233
|
+
for await (const chunk of req) {
|
|
234
|
+
bytes += chunk.length;
|
|
235
|
+
if (bytes > MAX_REFRESH_BODY_BYTES) {
|
|
236
|
+
throw new Error('refresh body is too large');
|
|
237
|
+
}
|
|
238
|
+
chunks.push(chunk);
|
|
239
|
+
}
|
|
240
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
241
|
+
if (!raw) return undefined;
|
|
242
|
+
let parsed;
|
|
243
|
+
try {
|
|
244
|
+
parsed = JSON.parse(raw);
|
|
245
|
+
} catch {
|
|
246
|
+
throw new Error('refresh body must be JSON');
|
|
247
|
+
}
|
|
248
|
+
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
249
|
+
throw new Error('refresh body must be a JSON object');
|
|
250
|
+
}
|
|
251
|
+
if (!('ref' in parsed) || parsed.ref == null) return undefined;
|
|
252
|
+
// normalizeLocalSourceRef enforces the git-argument safety rules.
|
|
253
|
+
return normalizeLocalSourceRef(parsed.ref);
|
|
254
|
+
}
|
|
255
|
+
|
|
176
256
|
async function restoreManagedOutputs(mirrorRoot, managedPaths) {
|
|
177
257
|
if (managedPaths.length === 0) return;
|
|
178
258
|
const trackedRaw = await git(mirrorRoot, 'ls-files', '-z', '--', ...managedPaths);
|
|
@@ -191,13 +271,46 @@ async function restoreManagedOutputs(mirrorRoot, managedPaths) {
|
|
|
191
271
|
await git(mirrorRoot, 'clean', '-fd', '--', ...managedPaths);
|
|
192
272
|
}
|
|
193
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Turns the current working tree into a commit the mirror can check out, so
|
|
276
|
+
* uncommitted edits can be previewed without polluting any branch.
|
|
277
|
+
*
|
|
278
|
+
* `git stash create` writes a dangling commit and leaves the working tree and
|
|
279
|
+
* the index untouched. It records tracked changes only — untracked files stay
|
|
280
|
+
* out, which the caller reports as a warning.
|
|
281
|
+
*/
|
|
282
|
+
async function resolveWorktreeSourceCommit(repoRoot, onWarning) {
|
|
283
|
+
const created = await git(repoRoot, 'stash', 'create');
|
|
284
|
+
if (!created) {
|
|
285
|
+
// Nothing uncommitted — HEAD already is the working tree.
|
|
286
|
+
return git(repoRoot, 'rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}');
|
|
287
|
+
}
|
|
288
|
+
const untracked = await git(
|
|
289
|
+
repoRoot,
|
|
290
|
+
'ls-files',
|
|
291
|
+
'--others',
|
|
292
|
+
'--exclude-standard',
|
|
293
|
+
);
|
|
294
|
+
if (untracked) {
|
|
295
|
+
const count = untracked.split('\n').filter(Boolean).length;
|
|
296
|
+
onWarning?.(
|
|
297
|
+
`working tree preview excludes ${count} untracked file(s); commit or stage them to include`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return created;
|
|
301
|
+
}
|
|
302
|
+
|
|
194
303
|
export async function syncDevMirrorWorktree(options) {
|
|
195
304
|
const repoRoot = path.resolve(options.repoRoot);
|
|
196
305
|
const mirrorRoot = path.resolve(options.mirrorRoot);
|
|
197
306
|
const branch = options.branch ?? 'dev';
|
|
198
307
|
const remote = options.remote ?? 'origin';
|
|
199
308
|
const remoteRef = `${remote}/${branch}`;
|
|
200
|
-
const
|
|
309
|
+
const requestedRef = normalizeLocalSourceRef(options.ref);
|
|
310
|
+
const worktreeMode = requestedRef === PYGMALION_WORKTREE_SOURCE_REF;
|
|
311
|
+
const localRef = worktreeMode
|
|
312
|
+
? await resolveWorktreeSourceCommit(repoRoot, options.onWarning)
|
|
313
|
+
: requestedRef;
|
|
201
314
|
const managedPaths = normalizeManagedOutputPaths(options.managedPaths);
|
|
202
315
|
let warning = null;
|
|
203
316
|
let switchTarget = null;
|
|
@@ -284,7 +397,8 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
284
397
|
const repoRoot = path.resolve(options.projectRoot ?? path.resolve(editorRoot, '..'));
|
|
285
398
|
const branch = options.branch ?? 'dev';
|
|
286
399
|
const remote = options.remote ?? 'origin';
|
|
287
|
-
|
|
400
|
+
// Mutable so the editor can repoint the mirror without a server restart.
|
|
401
|
+
let ref = normalizeLocalSourceRef(options.ref);
|
|
288
402
|
const appDirectory = options.appDirectory ?? path.relative(repoRoot, editorRoot);
|
|
289
403
|
const mirrorRoot = path.resolve(
|
|
290
404
|
process.env.PYGMALION_DEV_WORKTREE ||
|
|
@@ -322,6 +436,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
322
436
|
state: 'idle',
|
|
323
437
|
commit: null,
|
|
324
438
|
shortCommit: null,
|
|
439
|
+
sourceRef: ref,
|
|
325
440
|
syncedAt: null,
|
|
326
441
|
updated: false,
|
|
327
442
|
warning: null,
|
|
@@ -510,7 +625,14 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
510
625
|
await waitUntilReady(previewPort, mirrorAppRoot, prefix);
|
|
511
626
|
};
|
|
512
627
|
|
|
513
|
-
const syncMirror = async () => {
|
|
628
|
+
const syncMirror = async (nextRef) => {
|
|
629
|
+
if (nextRef !== undefined) {
|
|
630
|
+
const normalized = normalizeLocalSourceRef(nextRef);
|
|
631
|
+
// A ref change invalidates the mirror checkout, so it must not land while
|
|
632
|
+
// a sync is already materializing the previous one.
|
|
633
|
+
if (syncPromise) await syncPromise.catch(() => undefined);
|
|
634
|
+
ref = normalized;
|
|
635
|
+
}
|
|
514
636
|
if (syncPromise) return syncPromise;
|
|
515
637
|
syncPromise = (async () => {
|
|
516
638
|
status = { ...status, state: 'syncing', error: null, warning: null };
|
|
@@ -539,6 +661,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
539
661
|
state: 'ready',
|
|
540
662
|
commit,
|
|
541
663
|
shortCommit,
|
|
664
|
+
sourceRef: ref,
|
|
542
665
|
syncedAt: new Date().toISOString(),
|
|
543
666
|
updated: previous !== commit,
|
|
544
667
|
warning,
|
|
@@ -578,8 +701,43 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
578
701
|
json(res, 200, status);
|
|
579
702
|
return;
|
|
580
703
|
}
|
|
704
|
+
if (url.pathname === `${control}/refs` && req.method === 'GET') {
|
|
705
|
+
try {
|
|
706
|
+
json(res, 200, {
|
|
707
|
+
refs: await listSourceRefs(repoRoot, remote),
|
|
708
|
+
sourceRef: ref,
|
|
709
|
+
defaultRef: `${remote}/${branch}`,
|
|
710
|
+
worktreeRef: PYGMALION_WORKTREE_SOURCE_REF,
|
|
711
|
+
});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
json(res, 500, {
|
|
714
|
+
ok: false,
|
|
715
|
+
error: error instanceof Error ? error.message : String(error),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
581
720
|
if (url.pathname === `${control}/refresh` && req.method === 'POST') {
|
|
582
|
-
|
|
721
|
+
let requestedRef;
|
|
722
|
+
try {
|
|
723
|
+
requestedRef = await readRefFromRequest(req);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
json(res, 400, {
|
|
726
|
+
ok: false,
|
|
727
|
+
error: error instanceof Error ? error.message : String(error),
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
let nextStatus;
|
|
732
|
+
try {
|
|
733
|
+
nextStatus = await syncMirror(requestedRef);
|
|
734
|
+
} catch (error) {
|
|
735
|
+
json(res, 400, {
|
|
736
|
+
ok: false,
|
|
737
|
+
error: error instanceof Error ? error.message : String(error),
|
|
738
|
+
});
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
583
741
|
json(res, nextStatus.state === 'ready' ? 200 : 409, nextStatus);
|
|
584
742
|
return;
|
|
585
743
|
}
|
|
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;
|
|
@@ -1543,6 +1545,37 @@ export interface PygmalionProjectRuntimeOptions {
|
|
|
1543
1545
|
) => string | undefined;
|
|
1544
1546
|
}
|
|
1545
1547
|
|
|
1548
|
+
/** One revision the mirror can be repointed at. */
|
|
1549
|
+
export interface PygmalionSourceRef {
|
|
1550
|
+
name: string;
|
|
1551
|
+
commit: string;
|
|
1552
|
+
fullName: string;
|
|
1553
|
+
kind: 'local' | 'remote';
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Revisions available to `switchSource`, listed from the mirror repository. */
|
|
1557
|
+
export interface PygmalionSourceRefs {
|
|
1558
|
+
refs: readonly PygmalionSourceRef[];
|
|
1559
|
+
/** Revision followed when none is pinned. */
|
|
1560
|
+
defaultRef: string | null;
|
|
1561
|
+
/** Ref that renders uncommitted work. */
|
|
1562
|
+
worktreeRef: string | null;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
export interface SourceRefControlProps {
|
|
1566
|
+
value: string | null;
|
|
1567
|
+
refs: readonly PygmalionSourceRef[];
|
|
1568
|
+
onChange(ref: string): void;
|
|
1569
|
+
busy?: boolean;
|
|
1570
|
+
defaultRef?: string | null;
|
|
1571
|
+
worktreeRef?: string | null;
|
|
1572
|
+
label?: string;
|
|
1573
|
+
className?: string;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** Selects the revision the dev mirror follows. Owned by Pygmalion, not by the host. */
|
|
1577
|
+
export declare function SourceRefControl(props: SourceRefControlProps): ReactElement;
|
|
1578
|
+
|
|
1546
1579
|
export interface PygmalionProjectRuntime {
|
|
1547
1580
|
mirror: PygmalionDevMirrorStatus;
|
|
1548
1581
|
session: PygmalionDesignSessionStatus | null;
|
|
@@ -1555,7 +1588,22 @@ export interface PygmalionProjectRuntime {
|
|
|
1555
1588
|
* namespaces from it instead of parsing previewRevision themselves.
|
|
1556
1589
|
*/
|
|
1557
1590
|
previewSourceRevision: string;
|
|
1558
|
-
refresh(manual?: boolean): Promise<void>;
|
|
1591
|
+
refresh(manual?: boolean, sourceRef?: string): Promise<void>;
|
|
1592
|
+
/**
|
|
1593
|
+
* Repoints the mirror at another git ref without restarting the dev server.
|
|
1594
|
+
* Pass PYGMALION_WORKTREE_SOURCE_REF to preview uncommitted work.
|
|
1595
|
+
* The artifact cache is keyed by source SHA, so a switch invalidates it.
|
|
1596
|
+
*/
|
|
1597
|
+
switchSource(
|
|
1598
|
+
sourceRef: string,
|
|
1599
|
+
options?: { discardEdits?: boolean },
|
|
1600
|
+
): Promise<void>;
|
|
1601
|
+
/**
|
|
1602
|
+
* Revisions `switchSource` can be given. Empty until listed, so a host can
|
|
1603
|
+
* offer a choice instead of asking for a ref string.
|
|
1604
|
+
*/
|
|
1605
|
+
sourceRefs: PygmalionSourceRefs;
|
|
1606
|
+
loadSourceRefs(): Promise<PygmalionSourceRefs>;
|
|
1559
1607
|
previewVisual(payload: InspectApplyPayload): Promise<InspectPreviewResult>;
|
|
1560
1608
|
inspectImpact(componentFiles: string[]): Promise<InspectImpactResult>;
|
|
1561
1609
|
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';
|