parley-live 0.5.0 → 0.6.0

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.
@@ -0,0 +1,113 @@
1
+ // The Lenses a person has made, and getting back to one.
2
+ //
3
+ // A build used to end with the files somewhere only the pipeline knew about. These two calls are what
4
+ // make a finished Lens something the person owns: a list they can see, and a way back into Lens Studio.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { Bridge, DATA, PROJECTS } from './lib/bridge.mjs';
8
+
9
+ /** Every Lens on this computer, newest first, with where it lives and how its last build went. */
10
+ export function listLenses() {
11
+ if (!fs.existsSync(PROJECTS)) return [];
12
+ const runs = readRuns();
13
+ return fs.readdirSync(PROJECTS, { withFileTypes: true })
14
+ .filter((d) => d.isDirectory() && d.name !== '_templates')
15
+ .map((d) => {
16
+ const dir = path.join(PROJECTS, d.name);
17
+ const esproj = fs.readdirSync(dir).find((f) => f.toLowerCase().endsWith('.esproj'));
18
+ if (!esproj) return null;
19
+ const run = runs.find((r) => sameName(r.title, d.name) || sameName(r.lens_id, d.name)) ?? null;
20
+ return {
21
+ name: run?.title || d.name,
22
+ folder: d.name,
23
+ dir,
24
+ file: path.join(dir, esproj),
25
+ builtAt: run?.finished_at ?? fs.statSync(path.join(dir, esproj)).mtime.toISOString(),
26
+ steps: run?.steps ?? null,
27
+ passed: run?.pass ?? null,
28
+ reportPath: run?.reportPath ?? null,
29
+ };
30
+ })
31
+ .filter(Boolean)
32
+ .sort((a, b) => String(b.builtAt).localeCompare(String(a.builtAt)));
33
+ }
34
+
35
+ /**
36
+ * Put a Lens back in front of the person.
37
+ * where 'lens_studio' (default) opens it in Lens Studio, which is where Send to Spectacles lives.
38
+ * where 'folder' only resolves the path; revealing a folder belongs to the app, not to this process.
39
+ */
40
+ export async function openLens(target, where = 'lens_studio', log = () => {}) {
41
+ const { resolveProject, openProject } = await import('./publish.mjs');
42
+ const file = resolveProject(target || '');
43
+ if (!file) {
44
+ const known = listLenses().map((l) => l.name);
45
+ throw new Error(`No Lens called "${target}" on this computer.${known.length ? ` Built so far: ${known.join(', ')}.` : ' Nothing has been built yet.'}`);
46
+ }
47
+ const dir = path.dirname(file);
48
+ if (where === 'folder') return { file, dir, opened: 'folder' };
49
+ log(`opening ${path.basename(file)} in Lens Studio`);
50
+ const bridge = new Bridge();
51
+ await bridge.connect();
52
+ await openProject(bridge, file);
53
+ return { file, dir, opened: 'lens_studio' };
54
+ }
55
+
56
+ function readRuns() {
57
+ const runsDir = path.join(DATA, 'runs');
58
+ if (!fs.existsSync(runsDir)) return [];
59
+ return fs.readdirSync(runsDir)
60
+ .map((name) => {
61
+ const metrics = path.join(runsDir, name, 'metrics.json');
62
+ if (!fs.existsSync(metrics)) return null;
63
+ try {
64
+ const m = JSON.parse(fs.readFileSync(metrics, 'utf8'));
65
+ return { ...m, reportPath: path.join(runsDir, name, 'report.md') };
66
+ } catch { return null; }
67
+ })
68
+ .filter(Boolean)
69
+ .sort((a, b) => String(b.finished_at).localeCompare(String(a.finished_at)));
70
+ }
71
+
72
+ const sameName = (a, b) => String(a ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '') === String(b ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
73
+
74
+ /**
75
+ * Put a Lens on the paired Spectacles: the "Send to Spectacles" step, done by Parley.
76
+ *
77
+ * Snap exposes it over MCP as push_leaf_lens, which pushes whatever project is open, so the Lens is
78
+ * opened first. The execution target has to be switched to the device or the push goes to the
79
+ * preview and reports success, which would be a lie. On failure the target is put back, so Lens
80
+ * Studio is left the way it was found.
81
+ */
82
+ export async function sendToGlasses(target, log = () => {}) {
83
+ const { resolveProject, openProject } = await import('./publish.mjs');
84
+ const file = resolveProject(target || '');
85
+ if (!file) throw new Error(`No Lens called "${target}" on this computer.`);
86
+ // Nobody should sit and watch a spinner for four minutes to learn their glasses are not on.
87
+ const bridge = new Bridge({ timeoutMs: 90000 });
88
+ await bridge.connect();
89
+ log('opening the Lens in Lens Studio');
90
+ await openProject(bridge, file);
91
+ await bridge.call('open_leaf_panel', {});
92
+ log('pointing Lens Studio at your Spectacles');
93
+ const aimed = await bridge.call('set_leaf_execution_target', { onDevice: true });
94
+ if (!aimed || aimed.target !== 'device') {
95
+ await bridge.call('set_leaf_execution_target', { onDevice: false }).catch(() => {});
96
+ throw new Error('Lens Studio would not switch to the glasses. Pair your Spectacles in Lens Studio first.');
97
+ }
98
+ log('sending it across');
99
+ try {
100
+ const pushed = await bridge.call('push_leaf_lens', {});
101
+ if (!pushed || pushed.pushed !== true || pushed.target !== 'device') {
102
+ throw new Error(`The Lens went to the ${pushed && pushed.target ? pushed.target : 'preview'} instead of the glasses.`);
103
+ }
104
+ return { file, dir: path.dirname(file), sent: true, target: 'device' };
105
+ } catch (e) {
106
+ await bridge.call('set_leaf_execution_target', { onDevice: false }).catch(() => {});
107
+ // Snap's message for "nothing answered" is about a lens not starting; say what it means.
108
+ const notThere = /did not start on the device|no device|not paired|timed out|timeout|abort/i.test(e.message);
109
+ throw new Error(notThere
110
+ ? 'No Spectacles answered. Put them on and wake them, pair them in Lens Studio, and keep them on the same Wi-Fi as this computer, then ask again.'
111
+ : e.message);
112
+ }
113
+ }
Binary file
@@ -0,0 +1,88 @@
1
+ // Make the project folder something a person can open without being told which file to click.
2
+ //
3
+ // A Lens Studio project is a developer's folder: Assets, Packages, Cache, Workspaces, and the
4
+ // agent-tooling files Snap's template ships (.clad, .cursor, AGENTS.md and friends). Someone who
5
+ // asked for a training guide opens that and has no idea what any of it is. So after a build the
6
+ // folder is cut down to the two things that mean something to them: the Lens itself, and a note
7
+ // telling them what to do. Everything else is marked hidden. Nothing is deleted or moved, so Lens
8
+ // Studio still finds every file exactly where it expects.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { execFileSync } from 'node:child_process';
12
+
13
+ const README = 'Open me first.txt';
14
+
15
+ /** Hide Lens Studio's working files on Windows. Everywhere else this is a no-op. */
16
+ function hide(dir, names) {
17
+ if (process.platform !== 'win32' || !names.length) return 0;
18
+ const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
19
+ const attrib = path.join(sysRoot, 'System32', 'attrib.exe');
20
+ if (!fs.existsSync(attrib)) return 0;
21
+ let hidden = 0;
22
+ for (const name of names) {
23
+ try { execFileSync(attrib, ['+H', path.join(dir, name)], { stdio: 'pipe', windowsHide: true }); hidden++; }
24
+ catch { /* a file Lens Studio holds open can refuse; it stays visible, which is not worth failing a build over */ }
25
+ }
26
+ return hidden;
27
+ }
28
+
29
+ function readmeText({ title, esprojName, steps, completionMessage }) {
30
+ const lines = [
31
+ title,
32
+ '='.repeat(title.length),
33
+ '',
34
+ 'This folder is your Lens: the step-by-step guide Parley built for you.',
35
+ '',
36
+ 'TO OPEN IT',
37
+ ` Double-click "${esprojName}". That opens it in Lens Studio.`,
38
+ ' Or just ask Parley to open it. You do not have to come here at all.',
39
+ '',
40
+ 'TO PUT IT ON YOUR GLASSES',
41
+ ' Wake your Spectacles, make sure they are paired in Lens Studio and on',
42
+ ' the same Wi-Fi as this computer, then ask Parley to send it to your',
43
+ ' glasses. It does the rest.',
44
+ ' By hand instead: open it in Lens Studio and press "Send to Spectacles".',
45
+ '',
46
+ 'TO CHANGE IT',
47
+ ' Tell Parley what to change, in your own words. It rebuilds the whole',
48
+ ' Lens and tests it again. You do not edit anything in here by hand.',
49
+ '',
50
+ `WHAT IT GUIDES (${steps.length} step${steps.length === 1 ? '' : 's'})`,
51
+ ...steps.map((s, i) => ` ${i + 1}. ${s.instruction}${s.warning ? `\r\n Caution: ${s.warning}` : ''}`),
52
+ ];
53
+ if (completionMessage) lines.push('', `AT THE END, THE WEARER SEES`, ` ${completionMessage}`);
54
+ lines.push(
55
+ '',
56
+ 'THE REST OF THIS FOLDER',
57
+ ' Lens Studio keeps its own working files here. They are hidden because',
58
+ ' you never need to touch them. Do not move or rename anything: the Lens',
59
+ ' stops opening if you do.',
60
+ '',
61
+ `Built by Parley on ${new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.`,
62
+ '',
63
+ );
64
+ return lines.join('\r\n');
65
+ }
66
+
67
+ /**
68
+ * Leave the project folder in a state a non-developer can use.
69
+ * Returns what it did, so the build report and the app can say so.
70
+ */
71
+ export function tidyProjectFolder(projectDir, { title, esproj, steps = [], completionMessage = '' } = {}) {
72
+ const out = { readme: null, hidden: 0, esproj: esproj ? path.basename(esproj) : null };
73
+ if (!projectDir || !fs.existsSync(projectDir)) return out;
74
+ const esprojName = out.esproj || fs.readdirSync(projectDir).find((f) => f.toLowerCase().endsWith('.esproj')) || '';
75
+ out.esproj = esprojName || null;
76
+
77
+ const readmePath = path.join(projectDir, README);
78
+ try {
79
+ fs.writeFileSync(readmePath, readmeText({ title: title || esprojName.replace(/\.esproj$/i, ''), esprojName, steps, completionMessage }), 'utf8');
80
+ out.readme = readmePath;
81
+ } catch { /* a read-only folder is not a reason to fail a build that otherwise passed */ }
82
+
83
+ // Everything except the Lens and the note. Lens Studio reads hidden files exactly as before.
84
+ const keep = new Set([esprojName.toLowerCase(), README.toLowerCase()]);
85
+ const clutter = fs.readdirSync(projectDir).filter((n) => !keep.has(n.toLowerCase()));
86
+ out.hidden = hide(projectDir, clutter);
87
+ return out;
88
+ }
@@ -0,0 +1,246 @@
1
+ // Everything Snap requires of a Lens before it can be submitted, done at build time.
2
+ //
3
+ // Three of the four requirements can be met on this computer with no Snap account:
4
+ // - a valid Lens name. Snap's Editor.Model.MetaInfo.isLensNameValid accepts at most 18 characters
5
+ // (probed against Lens Studio 5.24.0: 18 ok, 19 rejected; spaces, digits, - : & ' all fine,
6
+ // trailing space rejected). A project copied from Snap's Base Template arrives named
7
+ // "SpecsBaseTemplate", which is what would otherwise reach Snap.
8
+ // - a 320x320 PNG Lens icon (the size Snap's own generate-lens-icon.py produces). Written here
9
+ // with zlib alone so it works from the desktop app and from the npm CLI with no extra dependency.
10
+ // - a production signing key, which Snap's own signing-key.js makes from Node built-ins.
11
+ //
12
+ // The fourth, being signed in to Lens Studio, is Snap's gate: it blocks their preflight, the SPK
13
+ // export and every authorized call. Nothing here pretends to work around it.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import zlib from 'node:zlib';
17
+ import crypto from 'node:crypto';
18
+
19
+ export const SNAP_LENS_NAME_MAX = 18;
20
+ const TRAILING_FILLER = new Set(['and', 'or', 'the', 'a', 'an', 'of', 'for', 'to', 'with', 'on', 'in', 'at', 'by', 'per']);
21
+
22
+ /** A Lens name Snap will accept, derived from the procedure name. Deterministic. */
23
+ export function snapLensName(procedureName, override) {
24
+ const clean = (s) => String(s || '').replace(/[^A-Za-z0-9 \-:&']/g, ' ').replace(/\s+/g, ' ').trim();
25
+ if (override) {
26
+ const o = clean(override);
27
+ if (o && o.length <= SNAP_LENS_NAME_MAX) return o;
28
+ }
29
+ const words = clean(procedureName).split(' ').filter(Boolean);
30
+ if (!words.length) return 'Parley Guide';
31
+ const kept = [];
32
+ for (const w of words) {
33
+ const next = kept.length ? `${kept.join(' ')} ${w}` : w;
34
+ if (next.length > SNAP_LENS_NAME_MAX) break;
35
+ kept.push(w);
36
+ }
37
+ while (kept.length > 1 && TRAILING_FILLER.has(kept[kept.length - 1].toLowerCase())) kept.pop();
38
+ const name = kept.join(' ').trim();
39
+ if (name) return name;
40
+ return clean(procedureName).slice(0, SNAP_LENS_NAME_MAX).trim() || 'Parley Guide';
41
+ }
42
+
43
+ // ---------------------------------------------------------------- 320x320 PNG, no dependencies
44
+ const CRC_TABLE = (() => {
45
+ const t = new Int32Array(256);
46
+ for (let n = 0; n < 256; n++) {
47
+ let c = n;
48
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
49
+ t[n] = c;
50
+ }
51
+ return t;
52
+ })();
53
+
54
+ function crc32(buf) {
55
+ let c = -1;
56
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
57
+ return (c ^ -1) >>> 0;
58
+ }
59
+
60
+ function chunk(type, data) {
61
+ const len = Buffer.alloc(4);
62
+ len.writeUInt32BE(data.length, 0);
63
+ const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
64
+ const crc = Buffer.alloc(4);
65
+ crc.writeUInt32BE(crc32(body), 0);
66
+ return Buffer.concat([len, body, crc]);
67
+ }
68
+
69
+ function encodePng(width, height, rgb) {
70
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
71
+ const ihdr = Buffer.alloc(13);
72
+ ihdr.writeUInt32BE(width, 0);
73
+ ihdr.writeUInt32BE(height, 4);
74
+ ihdr[8] = 8; // bit depth
75
+ ihdr[9] = 2; // colour type: truecolour
76
+ const raw = Buffer.alloc(height * (1 + width * 3));
77
+ for (let y = 0; y < height; y++) {
78
+ const rowStart = y * (1 + width * 3);
79
+ raw[rowStart] = 0; // filter: none
80
+ rgb.copy(raw, rowStart + 1, y * width * 3, (y + 1) * width * 3);
81
+ }
82
+ return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
83
+ }
84
+
85
+ const mix = (a, b, t) => [0, 1, 2].map((i) => Math.round(a[i] + (b[i] - a[i]) * t));
86
+
87
+ function hsl(h, s, l) {
88
+ const c = (1 - Math.abs(2 * l - 1)) * s;
89
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
90
+ const m = l - c / 2;
91
+ const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
92
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
93
+ }
94
+
95
+ /**
96
+ * The Lens icon: a dark panel with a stack of step bars, the current one lit. It is what the Lens
97
+ * actually is, and it stays legible at the small size a Lens icon is shown at. The accent hue comes
98
+ * from the name, so two procedures never look identical in a list.
99
+ */
100
+ export function makeLensIcon(lensName, outPath, size = 320) {
101
+ const hash = crypto.createHash('sha256').update(String(lensName)).digest();
102
+ const hue = (hash[0] / 255) * 360;
103
+ const accent = hsl(hue, 0.72, 0.58);
104
+ const accentDim = hsl(hue, 0.38, 0.32);
105
+ const bgTop = [16, 22, 30];
106
+ const bgBottom = [8, 11, 16];
107
+ const panel = [26, 34, 45];
108
+
109
+ const px = Buffer.alloc(size * size * 3);
110
+ const put = (x, y, c) => { const o = (y * size + x) * 3; px[o] = c[0]; px[o + 1] = c[1]; px[o + 2] = c[2]; };
111
+
112
+ // rounded-rect membership with a soft edge, so nothing looks jagged at icon size
113
+ const inRounded = (x, y, rx, ry, rw, rh, r) => {
114
+ const cx = Math.min(Math.max(x, rx + r), rx + rw - r);
115
+ const cy = Math.min(Math.max(y, ry + r), ry + rh - r);
116
+ const d = Math.hypot(x - cx, y - cy);
117
+ if (x < rx || x > rx + rw || y < ry || y > ry + rh) return 0;
118
+ return d <= r - 0.5 ? 1 : d >= r + 0.5 ? 0 : r + 0.5 - d;
119
+ };
120
+
121
+ const s = size / 320;
122
+ const panelX = 44 * s, panelY = 60 * s, panelW = 232 * s, panelH = 200 * s, panelR = 26 * s;
123
+ const barX = 70 * s, barR = 7 * s, barH = 14 * s;
124
+ const bars = [
125
+ { y: 96 * s, w: 180 * s, on: true },
126
+ { y: 136 * s, w: 144 * s, on: false },
127
+ { y: 176 * s, w: 162 * s, on: false },
128
+ { y: 216 * s, w: 110 * s, on: false },
129
+ ];
130
+
131
+ for (let y = 0; y < size; y++) {
132
+ const ground = mix(bgTop, bgBottom, y / (size - 1));
133
+ for (let x = 0; x < size; x++) {
134
+ let c = ground;
135
+ const inPanel = inRounded(x, y, panelX, panelY, panelW, panelH, panelR);
136
+ if (inPanel > 0) c = mix(c, panel, inPanel);
137
+ for (const b of bars) {
138
+ const a = inRounded(x, y, barX, b.y, b.w, barH, barR);
139
+ if (a > 0) c = mix(c, b.on ? accent : accentDim, a);
140
+ }
141
+ put(x, y, c);
142
+ }
143
+ }
144
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
145
+ fs.writeFileSync(outPath, encodePng(size, size, px));
146
+ return { path: outPath, size, accent: `#${accent.map((v) => v.toString(16).padStart(2, '0')).join('')}` };
147
+ }
148
+
149
+ // ---------------------------------------------------------------- applied inside Lens Studio
150
+ const SET_METADATA = `const E: any = (globalThis as any).Editor;
151
+ const a: any = args;
152
+ const FileSystem: any = await import("LensStudio:FileSystem");
153
+ const model: any = pluginSystem.findInterface(E.Model.IModel);
154
+ const project: any = model.project;
155
+ const before = String(project.metaInfo.lensName || "");
156
+ if (!E.Model.MetaInfo.isLensNameValid(String(a.lensName))) {
157
+ return { error: "Lens Studio rejected the Lens name: " + a.lensName };
158
+ }
159
+ let meta: any = project.metaInfo;
160
+ meta.lensName = String(a.lensName);
161
+ project.metaInfo = meta;
162
+ if (a.iconPath) {
163
+ const src = new E.Path(String(a.iconPath));
164
+ if (!FileSystem.exists(src)) return { error: "Lens icon not found: " + a.iconPath };
165
+ meta = project.metaInfo;
166
+ meta.setIcon(src);
167
+ project.metaInfo = meta;
168
+ }
169
+ if (a.signingKeyPath) {
170
+ const key = new E.Path(String(a.signingKeyPath));
171
+ if (!FileSystem.exists(key)) return { error: "Signing key not found: " + a.signingKeyPath };
172
+ meta = project.metaInfo;
173
+ meta.spkProductionKeyPath = key;
174
+ project.metaInfo = meta;
175
+ }
176
+ project.save();
177
+ const keyOut = project.metaInfo.spkProductionKeyPath;
178
+ return {
179
+ before,
180
+ lensName: String(project.metaInfo.lensName || ""),
181
+ iconSet: !!project.metaInfo.isIconSet,
182
+ iconPath: String(project.metaInfo.iconPath || ""),
183
+ signingKeyPath: keyOut && !keyOut.isEmpty ? keyOut.toString() : "",
184
+ };`;
185
+
186
+ /**
187
+ * The production signing key Snap's SPK export needs: an ed25519 private key as pkcs8 PEM, which is
188
+ * the exact shape Snap's own signing-key.js writes. Node's crypto makes it; no account, no network.
189
+ * Reused when one is already on disk, so rebuilding a Lens keeps its identity.
190
+ */
191
+ export function ensureSigningKey(projectDir) {
192
+ const keyPath = path.join(projectDir, 'specs_signing_key.pem');
193
+ if (fs.existsSync(keyPath) && /BEGIN PRIVATE KEY/.test(fs.readFileSync(keyPath, 'utf8'))) {
194
+ return { path: keyPath, created: false };
195
+ }
196
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
197
+ fs.writeFileSync(keyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
198
+ try { fs.chmodSync(keyPath, 0o600); } catch { /* windows */ }
199
+ return { path: keyPath, created: true };
200
+ }
201
+
202
+ /**
203
+ * Give the freshly built project the Lens name, icon and signing key Snap asks for.
204
+ * Returns what was set, so the verifier can check it rather than assume it.
205
+ */
206
+ export async function prepareSubmission(bridge, { projectDir, procedureName, lensName }) {
207
+ const name = snapLensName(procedureName, lensName);
208
+ const icon = makeLensIcon(name, path.join(projectDir, 'Cache', 'parley-lens-icon.png'));
209
+ const key = ensureSigningKey(projectDir);
210
+ const fwd = (p) => p.split(path.sep).join('/');
211
+ const applied = await bridge.eec(SET_METADATA, { lensName: name, iconPath: fwd(icon.path), signingKeyPath: fwd(key.path) }, 90000);
212
+ if (applied?.error) throw new Error(applied.error);
213
+ return {
214
+ lensName: applied.lensName,
215
+ inheritedName: applied.before,
216
+ nameWasTemplateDefault: /^SpecsBaseTemplate$/i.test(applied.before || ''),
217
+ iconPath: applied.iconPath,
218
+ iconAccent: icon.accent,
219
+ signingKeyPath: applied.signingKeyPath,
220
+ signingKeyCreated: key.created,
221
+ };
222
+ }
223
+
224
+ /** Read back what Snap would see, including whether Lens Studio is signed in. */
225
+ export function readSubmissionState(bridge) {
226
+ return bridge.eec(`const E: any = (globalThis as any).Editor;
227
+ const model: any = pluginSystem.findInterface(E.Model.IModel);
228
+ const project: any = model.project;
229
+ const FileSystem: any = await import("LensStudio:FileSystem");
230
+ let signedIn: any = null;
231
+ try {
232
+ const auth: any = pluginSystem.findInterface(E.IAuthorization.interfaceId);
233
+ signedIn = !E.isNull(auth) && !!auth.isAuthorized;
234
+ } catch (e) { signedIn = null; }
235
+ const name = String(project.metaInfo.lensName || "");
236
+ const icon = String(project.metaInfo.iconPath || "");
237
+ const key = project.metaInfo.spkProductionKeyPath;
238
+ return {
239
+ lensName: name,
240
+ lensNameValid: !!name && E.Model.MetaInfo.isLensNameValid(name) && name.toLowerCase() !== "untitled" && name !== "SpecsBaseTemplate",
241
+ iconPath: icon,
242
+ iconPresent: !!project.metaInfo.isIconSet && !!icon && FileSystem.exists(new E.Path(icon)),
243
+ productionKeySet: !!key && !key.isEmpty && FileSystem.exists(key),
244
+ signedIn,
245
+ };`, {}, 60000);
246
+ }
@@ -0,0 +1,145 @@
1
+ // Takes a built Lens as far towards Snap as this computer can: Snap's own preflight, then Snap's own
2
+ // production SnapOS Package (SPK) export. Both run from lens-builder/vendor/specs-publish, which is
3
+ // Snap's Apache-2.0 toolchain copied unmodified; see that folder's NOTICE.
4
+ //
5
+ // The wall is Lens Studio's own sign-in. Snap's preflight refuses without it and their exporter checks
6
+ // Editor.IAuthorization.isAuthorized directly, so no Lens can be packaged for Snap by anyone, through
7
+ // Parley or by hand, until someone signs in to Lens Studio. Everything on this side of that line
8
+ // (a Lens name Snap accepts, the icon, the production signing key) is done at build time.
9
+ //
10
+ // Registering and submitting the release is deliberately not automated: it is irreversible and needs
11
+ // choices only the owner can make (organisation, category, content rating).
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { Bridge, DATA, PROJECTS } from './lib/bridge.mjs';
16
+ import { readSubmissionState } from './lib/submission.mjs';
17
+
18
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
19
+ const SNAP_SCRIPTS = path.join(HERE, 'vendor', 'specs-publish', 'scripts');
20
+ const fwd = (p) => p.split(path.sep).join('/');
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+
23
+ /** The .esproj for a lens id, a run folder name, or an explicit path. */
24
+ /**
25
+ * A Lens can be asked for by its id, by the folder the person sees in Documents, or by the name they
26
+ * gave the procedure. All three have to find it, because the name in chat is rarely the id.
27
+ */
28
+ function findProjectDir(lensId) {
29
+ if (!fs.existsSync(PROJECTS)) return null;
30
+ // Never hand back the caller's spelling: Windows matches paths case-insensitively, so "espresso
31
+ // machine" would resolve and then be shown to the person instead of the folder they actually have.
32
+ const dirs = fs.readdirSync(PROJECTS, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name !== '_templates');
33
+ const exact = dirs.find((d) => d.name === lensId);
34
+ if (exact) return path.join(PROJECTS, exact.name);
35
+ const want = String(lensId).toLowerCase().replace(/[^a-z0-9]+/g, '');
36
+ const hit = dirs.find((d) => d.name.toLowerCase().replace(/[^a-z0-9]+/g, '') === want)
37
+ || dirs.find((d) => d.name.toLowerCase().replace(/[^a-z0-9]+/g, '').includes(want) && want.length > 3);
38
+ return hit ? path.join(PROJECTS, hit.name) : null;
39
+ }
40
+
41
+ export function resolveProject(target) {
42
+ if (!target) return null;
43
+ if (/\.esproj$/i.test(target) && fs.existsSync(target)) return path.resolve(target);
44
+ const asRun = path.join(DATA, 'runs', target, 'metrics.json');
45
+ const lensId = fs.existsSync(asRun) ? JSON.parse(fs.readFileSync(asRun, 'utf8')).lens_id : target;
46
+ const dir = findProjectDir(lensId);
47
+ if (!dir) return null;
48
+ const esproj = fs.readdirSync(dir).find((f) => f.toLowerCase().endsWith('.esproj'));
49
+ return esproj ? path.join(dir, esproj) : null;
50
+ }
51
+
52
+ export async function openProject(bridge, esproj) {
53
+ const current = await bridge.projectInfo().catch(() => null);
54
+ if (current && fwd(current.file).toLowerCase() === fwd(esproj).toLowerCase()) return current;
55
+ await bridge.eec(`const E: any = (globalThis as any).Editor; const model: any = pluginSystem.findInterface(E.Model.IModel);
56
+ model.openProject(new E.Path(String((args as any).file))); return "opening";`, { file: fwd(esproj) });
57
+ for (let i = 0; i < 30; i++) {
58
+ await sleep(2000);
59
+ const info = await bridge.projectInfo().catch(() => null);
60
+ if (info && fwd(info.file).toLowerCase() === fwd(esproj).toLowerCase()) { await sleep(2000); return info; }
61
+ }
62
+ throw new Error(`Timed out opening ${esproj}`);
63
+ }
64
+
65
+ const SIGN_IN_HELP = 'Sign in to Lens Studio: My Lenses > Login, in the Lens Studio menu bar. That is Snap\'s gate. Until someone signs in, no Lens can be packaged or submitted for Snap by any route, and Parley cannot do it for you.';
66
+
67
+ /**
68
+ * @param target lens id, run folder name, or a path to a .esproj. Omit to use whatever is open.
69
+ * @returns { stage, ok, signedIn, ready, spkPath, checksum, issues[], next[] }
70
+ */
71
+ export async function publish(target, { log = () => {} } = {}) {
72
+ const out = { stage: 'preflight', ok: false, signedIn: null, ready: false, spkPath: null, checksum: null, issues: [], next: [] };
73
+ const bridge = new Bridge();
74
+ await bridge.connect();
75
+
76
+ const esproj = resolveProject(target);
77
+ if (target && !esproj) throw new Error(`No built Lens found for "${target}". Run parley lens metrics to see what is on this computer.`);
78
+ if (esproj) { log(`opening ${path.basename(esproj)}`); await openProject(bridge, esproj); }
79
+ const info = await bridge.projectInfo();
80
+ out.project = info.file;
81
+
82
+ // What Parley set at build time, read back from the project rather than assumed.
83
+ const state = await readSubmissionState(bridge).catch(() => null);
84
+ if (state) {
85
+ out.signedIn = state.signedIn;
86
+ out.lensName = state.lensName;
87
+ out.readyLocally = !!(state.lensNameValid && state.iconPresent && state.productionKeySet);
88
+ if (!state.lensNameValid) out.issues.push(`The Lens name "${state.lensName}" is not one Snap accepts. Rebuild the Lens so Parley sets it.`);
89
+ if (!state.iconPresent) out.issues.push('No Lens icon on the project. Rebuild the Lens so Parley sets it.');
90
+ if (!state.productionKeySet) out.issues.push('No production signing key on the project. Rebuild the Lens so Parley attaches one.');
91
+ }
92
+
93
+ // Snap's own preflight. It returns immediately on auth, so its verdict on the rest is only
94
+ // visible once signed in; Parley checks name, icon and key itself above regardless.
95
+ log('running Snap\'s preflight');
96
+ const pre = await bridge.eecPath(path.join(SNAP_SCRIPTS, 'preflight.ts'), {}, 120000).catch((e) => ({ status: 'FAILED', reason: 'preflight_error', message: e.message }));
97
+ out.preflight = pre;
98
+
99
+ if (pre?.status === 'ACTION_REQUIRED' && pre.reason === 'not_signed_in') {
100
+ out.stage = 'blocked_on_sign_in';
101
+ out.signedIn = false;
102
+ out.next = [SIGN_IN_HELP, 'Then run this command again. Nothing else is missing on this computer.'];
103
+ return out;
104
+ }
105
+ if (pre?.status === 'ACTION_REQUIRED') {
106
+ out.stage = 'action_required';
107
+ out.issues.push(...(pre.issues?.map((i) => i.message) ?? [pre.message]).filter(Boolean));
108
+ out.next = ['Snap needs these before the Lens can be packaged. Fix them in Lens Studio, then run this again.'];
109
+ return out;
110
+ }
111
+ if (pre?.status !== 'READY') {
112
+ out.stage = 'preflight_failed';
113
+ out.issues.push(pre?.message || 'Snap\'s preflight did not return READY.');
114
+ return out;
115
+ }
116
+
117
+ out.ready = true;
118
+ out.signedIn = true;
119
+ out.packageId = pre.packageId;
120
+
121
+ // Snap's own exporter. Produces <project>/.export/<name>.spk, signed with the production key.
122
+ log('exporting the SnapOS Package');
123
+ out.stage = 'package';
124
+ const pkg = await bridge.eecPath(path.join(SNAP_SCRIPTS, 'package.ts'), {}, 300000);
125
+ out.package = pkg;
126
+ if (pkg?.status && pkg.status !== 'PACKAGED' && pkg.status !== 'READY') {
127
+ out.issues.push(pkg.message || 'The SnapOS Package export did not complete.');
128
+ return out;
129
+ }
130
+ out.spkPath = pkg?.spkPath || pkg?.packagePath || null;
131
+ out.checksum = pkg?.spkChecksum || pkg?.checksum || null;
132
+ out.bytes = pkg?.packageBytes ?? (out.spkPath && fs.existsSync(out.spkPath) ? fs.statSync(out.spkPath).size : null);
133
+ out.ok = !!out.spkPath;
134
+ out.stage = out.ok ? 'packaged' : 'package_failed';
135
+ out.next = out.ok
136
+ ? ['Register and submit the release from Lens Studio, or with Snap\'s authorized-request script. That step is irreversible and needs your organisation, category and content rating, so Parley stops here.']
137
+ : ['The export did not produce a package. Read the message above.'];
138
+ return out;
139
+ }
140
+
141
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(HERE, 'publish.mjs')) {
142
+ const r = await publish(process.argv[2], { log: (m) => console.log(' ' + m) });
143
+ console.log(JSON.stringify(r, null, 2));
144
+ process.exitCode = r.ok ? 0 : r.stage === 'blocked_on_sign_in' ? 3 : 1;
145
+ }