parley-live 0.5.0 → 0.5.1

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/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node
18
18
  import path from 'node:path';
19
19
  import { api, authWebUrl, backendUrl, clearCredentials, CliError, downloadTo, fileToDataUri, isUrl, loadCredentials, loadLast, openInBrowser, rememberLast, resolveRef, saveCredentials, sleep, } from './client.js';
20
20
  import { cmdLens, LensError } from './lens.js';
21
- const VERSION = '0.5.0';
21
+ const VERSION = '0.5.1';
22
22
  /** Server advice written for Parley's in-app agent names its tools; a CLI user
23
23
  * has commands instead, so the same advice is re-spoken in CLI terms. */
24
24
  function humanizeServerMessage(message) {
@@ -1005,7 +1005,7 @@ function help(topic) {
1005
1005
  engines: 'parley engines\n Which video engines this server can run right now and their credit rates.',
1006
1006
  'camera-moves': 'parley camera-moves\n The named camera-move presets accepted by `parley video --move`.',
1007
1007
  account: 'parley account\n Your credit balance.',
1008
- lens: 'parley lens <status|connect|build|describe|record|metrics>\n Build a Snap Specs Lens on this computer through Lens Studio: a step-by-step training guide with pinch buttons and optional voice shortcuts, tested in the preview before you see it. "describe" turns plain language into steps (about a cent, billed to your credits); "build <form.json>" is free. Run `parley lens status` first: it tells you exactly what to set up.',
1008
+ lens: 'parley lens <status|connect|build|describe|publish|record|metrics>\n Build a Snap Specs Lens on this computer through Lens Studio: a step-by-step training guide with pinch buttons and optional voice shortcuts, tested in the preview before you see it. "describe" turns plain language into steps (about a cent, billed to your credits); "build <form.json>" is free. Run `parley lens status` first: it tells you exactly what to set up. `publish` runs Snap\u0027s own preflight and SnapOS Package export (needs Lens Studio signed in).',
1009
1009
  };
1010
1010
  if (topic && lines[topic]) {
1011
1011
  console.log(lines[topic]);
package/dist/lens.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * parley lens connect --config '<json>' Save the config Lens Studio copies (AI Assistant > MCP > Configure Server)
8
8
  * parley lens build <form.json> Form -> tested Lens ($0, no model call)
9
9
  * parley lens describe "<procedure>" Plain language -> steps (billed, about a cent) -> tested Lens
10
+ * parley lens publish [<lens id>] Snap's preflight, then Snap's SnapOS Package export
10
11
  * parley lens record <run> [--on-glasses passed|issues] [--published submitted|published]
11
12
  * parley lens metrics
12
13
  */
@@ -198,6 +199,35 @@ export async function cmdLens(positionals, flags, jsonMode) {
198
199
  throw new LensError('The build did not pass. Read the report.', 6);
199
200
  return;
200
201
  }
202
+ if (sub === 'publish') {
203
+ const target = positionals[1];
204
+ const events = await runPipeline('publish', { target }, (e) => { if (!jsonMode && e.type === 'log')
205
+ console.log(' ' + e.message); });
206
+ const r = events[events.length - 1];
207
+ if (r.type === 'error')
208
+ throw new LensError(r.error, 1);
209
+ out(r, () => {
210
+ console.log('');
211
+ console.log(`Lens name for Snap: ${r.lensName ?? '(unknown)'}`);
212
+ console.log(`Name, icon and signing key on the project: ${r.readyLocally ? 'all set' : 'incomplete'}`);
213
+ if (r.issues?.length)
214
+ for (const i of r.issues)
215
+ console.log(' - ' + i);
216
+ if (r.ok) {
217
+ console.log(`\nSnapOS Package exported: ${r.spkPath}`);
218
+ if (r.bytes)
219
+ console.log(`${r.bytes} bytes${r.checksum ? ', checksum ' + r.checksum : ''}`);
220
+ }
221
+ else if (r.stage === 'blocked_on_sign_in') {
222
+ console.log('\nNot packaged: Lens Studio is not signed in.');
223
+ }
224
+ for (const n of r.next ?? [])
225
+ console.log('\n' + n);
226
+ });
227
+ if (!r.ok)
228
+ throw new LensError(r.stage === 'blocked_on_sign_in' ? 'Sign in to Lens Studio, then run this again.' : 'The Lens was not packaged.', r.stage === 'blocked_on_sign_in' ? 2 : 6);
229
+ return;
230
+ }
201
231
  if (sub === 'record') {
202
232
  const run = positionals[1];
203
233
  const hw = str(flags['on-glasses']);
@@ -218,5 +248,5 @@ export async function cmdLens(positionals, flags, jsonMode) {
218
248
  console.log('No Lenses built on this computer yet.'); });
219
249
  return;
220
250
  }
221
- throw new LensError(`Unknown: parley lens ${sub}. Use status | connect | build | describe | record | metrics.`, 64);
251
+ throw new LensError(`Unknown: parley lens ${sub}. Use status | connect | build | describe | publish | record | metrics.`, 64);
222
252
  }
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { Bridge, ROOT, DATA } from './lib/bridge.mjs';
7
7
  import { compile } from './training/compile.mjs';
8
8
  import { verify } from './verify.mjs';
9
+ import { prepareSubmission } from './lib/submission.mjs';
9
10
 
10
11
  const MAX_ITERATIONS = 5;
11
12
  const here = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
@@ -35,8 +36,10 @@ export async function build(form, { log = console.log, planner = null } = {}) {
35
36
  await bridge.connect();
36
37
  const project = await bridge.newProject(lens_id);
37
38
  log(`project: ${project.esproj}`);
39
+
38
40
  await bridge.specsPreview();
39
41
 
42
+ let submission = null;
40
43
  let report = null;
41
44
  for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) {
42
45
  result.iterations = iteration;
@@ -44,9 +47,19 @@ export async function build(form, { log = console.log, planner = null } = {}) {
44
47
  const authored = await bridge.eecFile(path.join(here, 'training', 'author-scene.ts'), { layout, scriptPath: scriptPath.split(path.sep).join('/') }, 90000);
45
48
  log(`iteration ${iteration}: authored ${authored.created.length} objects, script ${authored.script.mode}`);
46
49
 
50
+ // Snap will not take a Lens without a name it accepts, an icon and a production signing key.
51
+ // A project copied from Snap's Base Template arrives called "SpecsBaseTemplate"; left alone, that is
52
+ // the name that would reach Snap. Done after authoring, not straight after the project opens:
53
+ // writing metadata while Snap's UIKit package is still importing left the package half-resolved
54
+ // (its own textures then failed to load at runtime).
55
+ if (!submission) {
56
+ submission = await prepareSubmission(bridge, { projectDir: project.dir, procedureName: form.procedureName, lensName: form.lensName });
57
+ log(`for Snap: name "${submission.lensName}", icon set, signing key ${submission.signingKeyCreated ? 'generated' : 'reused'}`);
58
+ }
59
+
47
60
  // ---- Verifier
48
61
  result.stage = 'verify';
49
- report = await verify({ bridge, plan, form, runDir, iteration, projectDir: project.dir });
62
+ report = await verify({ bridge, plan, form, runDir, iteration, projectDir: project.dir, submission });
50
63
  fs.writeFileSync(path.join(runDir, 'verifier', `iteration-${iteration}.json`), JSON.stringify(report, null, 2));
51
64
  for (const c of report.checks) log(` ${c.status.toUpperCase().padEnd(8)} ${c.item}${c.detail ? ' -> ' + String(c.detail).slice(0, 160) : ''}`);
52
65
  if (report.pass) break;
@@ -62,7 +75,8 @@ export async function build(form, { log = console.log, planner = null } = {}) {
62
75
  result.stage = result.ok ? 'done' : 'escalated';
63
76
  result.seconds = Math.round((Date.now() - started) / 1000);
64
77
  result.costUsd = planner ? planner.costUsd : 0;
65
- fs.writeFileSync(path.join(runDir, 'report.md'), renderReport({ plan, form, report, result, project, planner }));
78
+ result.submission = submission;
79
+ fs.writeFileSync(path.join(runDir, 'report.md'), renderReport({ plan, form, report, result, project, planner, submission }));
66
80
  fs.writeFileSync(path.join(runDir, 'metrics.json'), JSON.stringify({ lens_id, title: plan.title, vertical: plan.vertical, source: planner ? 'prompt' : 'form', prompt: planner ? planner.prompt : null, pass: result.ok, iterations: result.iterations, seconds: result.seconds + (planner ? planner.seconds : 0), costUsd: result.costUsd, tokens: planner ? planner.usage : { input: 0, output: 0, calls: 0 }, steps: form.steps.length, hardwareItems: report.checks.filter((c) => c.status === 'hardware').length, hardwareTest: 'not tested', publishStatus: 'not published', finished_at: new Date().toISOString() }, null, 2));
67
81
  return { ...result, report };
68
82
  } catch (e) {
@@ -73,7 +87,7 @@ export async function build(form, { log = console.log, planner = null } = {}) {
73
87
  }
74
88
  }
75
89
 
76
- function renderReport({ plan, form, report, result, project, planner }) {
90
+ function renderReport({ plan, form, report, result, project, planner, submission }) {
77
91
  const line = (c) => `| ${c.status === 'pass' ? 'PASS' : c.status === 'fail' ? 'FAIL' : 'NEEDS HARDWARE'} | ${c.item} | ${String(c.detail ?? '').replace(/\|/g, '/').slice(0, 220)} |`;
78
92
  const cost = planner
79
93
  ? `USD ${planner.costUsd.toFixed(4)} (${planner.usage.input} in / ${planner.usage.output} out tokens, ${planner.model}, planning took ${planner.seconds}s)`
@@ -111,12 +125,17 @@ Source: https://developers.snap.com/spectacles/get-started/start-building/publis
111
125
  **Tests performed (preview):**
112
126
  ${report.checks.filter((c) => c.status === 'pass').map((c) => `- ${c.item}`).join('\n')}
113
127
 
128
+ **Lens name:** ${submission.lensName} (Snap accepts at most 18 characters; the full procedure name stays on the panel)
129
+ **Lens icon:** set, 320x320 PNG
130
+ **Signing key:** production key attached to the project
131
+
114
132
  **Tests Snap should repeat on device:** open the Lens, confirm "v${form.version || '1.0.0'}" shows under the buttons, pinch Done through all ${form.steps.length} step${form.steps.length === 1 ? '' : 's'} to the completion message, pinch Back once from step 2${form.voiceShortcuts !== false ? ', say "done" to advance' : ''}.
115
133
 
116
134
  ## Manual steps remaining
117
135
  - Put the Lens on glasses: pair Specs in Lens Studio, then Send to Spectacles.
118
136
  - Check frame rate and thermals on device with the Lens Performance Overlay (Snap targets 60 FPS).
119
- - Add a custom icon and a preview image or video in Project Info, then Publish in Lens Studio (not automated yet).
137
+ - Sign in to Lens Studio (My Lenses > Login). That is the one thing Parley cannot do for you, and it gates Snap's preflight, the SnapOS Package export and the upload.
138
+ - Add a preview image or video, then run the publish steps. Name, icon and signing key are already set on this project.
120
139
  `;
121
140
  }
122
141
 
@@ -3,6 +3,7 @@
3
3
  // node lens-builder/cli.mjs plan < {"prompt":""} -> { type:'planner', decision, form|question|reason, costUsd }
4
4
  // node lens-builder/cli.mjs build < {"form":{}, "planner":{}?} -> progress events, then { type:'result', ... }
5
5
  // node lens-builder/cli.mjs metrics -> { type:'metrics', lenses:[...] }
6
+ // node lens-builder/cli.mjs publish [lensId|run] -> Snap preflight + SnapOS Package export (needs Lens Studio sign-in)
6
7
  // node lens-builder/cli.mjs record < {"run":"","hardwareTest":"","publishStatus":"","notes":""} -> updated metrics row
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
@@ -75,6 +76,11 @@ try {
75
76
  if (cmd === 'status') await status();
76
77
  else if (cmd === 'metrics') metrics();
77
78
  else if (cmd === 'record') await record();
79
+ else if (cmd === 'publish') {
80
+ const { publish } = await import('./publish.mjs');
81
+ const { target } = await readStdin();
82
+ emit({ type: 'publish', ...(await publish(target, { log: (message) => emit({ type: 'log', message }) })) });
83
+ }
78
84
  else if (cmd === 'plan') {
79
85
  const { planFromPrompt } = await import('./planner.mjs');
80
86
  const { prompt, auth } = await readStdin();
@@ -83,6 +83,19 @@ export class Bridge {
83
83
  return out.returnValue;
84
84
  }
85
85
 
86
+ // Run a script that lives on disk by PATH, without reading it into this process. Snap's publish
87
+ // scripts are meant to run this way (preflight.ts alone is ~28 KB, and reading it costs that twice).
88
+ async eecPath(file, args = {}, timeoutMs = 120000) {
89
+ const out = await this.call('ExecuteEditorCode', { path: file.split(path.sep).join('/'), args, timeoutMs });
90
+ if (out.status !== 'Execution Succeeded') {
91
+ const detail = (out.errors || []).join(' ') || out.error || JSON.stringify(out).slice(0, 800);
92
+ const err = new Error(`${path.basename(file)}: ${out.status}: ${detail}`);
93
+ err.details = out;
94
+ throw err;
95
+ }
96
+ return out.returnValue;
97
+ }
98
+
86
99
  eecFile(file, args = {}, timeoutMs = 60000) {
87
100
  return this.eec(fs.readFileSync(file, 'utf8'), args, timeoutMs);
88
101
  }
@@ -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,127 @@
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 } 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
+ export function resolveProject(target) {
25
+ if (!target) return null;
26
+ if (/\.esproj$/i.test(target) && fs.existsSync(target)) return path.resolve(target);
27
+ const asRun = path.join(DATA, 'runs', target, 'metrics.json');
28
+ const lensId = fs.existsSync(asRun) ? JSON.parse(fs.readFileSync(asRun, 'utf8')).lens_id : target;
29
+ const dir = path.join(DATA, 'lens-projects', lensId);
30
+ const esproj = path.join(dir, `${lensId}.esproj`);
31
+ return fs.existsSync(esproj) ? esproj : null;
32
+ }
33
+
34
+ async function openProject(bridge, esproj) {
35
+ const current = await bridge.projectInfo().catch(() => null);
36
+ if (current && fwd(current.file).toLowerCase() === fwd(esproj).toLowerCase()) return current;
37
+ await bridge.eec(`const E: any = (globalThis as any).Editor; const model: any = pluginSystem.findInterface(E.Model.IModel);
38
+ model.openProject(new E.Path(String((args as any).file))); return "opening";`, { file: fwd(esproj) });
39
+ for (let i = 0; i < 30; i++) {
40
+ await sleep(2000);
41
+ const info = await bridge.projectInfo().catch(() => null);
42
+ if (info && fwd(info.file).toLowerCase() === fwd(esproj).toLowerCase()) { await sleep(2000); return info; }
43
+ }
44
+ throw new Error(`Timed out opening ${esproj}`);
45
+ }
46
+
47
+ 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.';
48
+
49
+ /**
50
+ * @param target lens id, run folder name, or a path to a .esproj. Omit to use whatever is open.
51
+ * @returns { stage, ok, signedIn, ready, spkPath, checksum, issues[], next[] }
52
+ */
53
+ export async function publish(target, { log = () => {} } = {}) {
54
+ const out = { stage: 'preflight', ok: false, signedIn: null, ready: false, spkPath: null, checksum: null, issues: [], next: [] };
55
+ const bridge = new Bridge();
56
+ await bridge.connect();
57
+
58
+ const esproj = resolveProject(target);
59
+ if (target && !esproj) throw new Error(`No built Lens found for "${target}". Run parley lens metrics to see what is on this computer.`);
60
+ if (esproj) { log(`opening ${path.basename(esproj)}`); await openProject(bridge, esproj); }
61
+ const info = await bridge.projectInfo();
62
+ out.project = info.file;
63
+
64
+ // What Parley set at build time, read back from the project rather than assumed.
65
+ const state = await readSubmissionState(bridge).catch(() => null);
66
+ if (state) {
67
+ out.signedIn = state.signedIn;
68
+ out.lensName = state.lensName;
69
+ out.readyLocally = !!(state.lensNameValid && state.iconPresent && state.productionKeySet);
70
+ if (!state.lensNameValid) out.issues.push(`The Lens name "${state.lensName}" is not one Snap accepts. Rebuild the Lens so Parley sets it.`);
71
+ if (!state.iconPresent) out.issues.push('No Lens icon on the project. Rebuild the Lens so Parley sets it.');
72
+ if (!state.productionKeySet) out.issues.push('No production signing key on the project. Rebuild the Lens so Parley attaches one.');
73
+ }
74
+
75
+ // Snap's own preflight. It returns immediately on auth, so its verdict on the rest is only
76
+ // visible once signed in; Parley checks name, icon and key itself above regardless.
77
+ log('running Snap\'s preflight');
78
+ const pre = await bridge.eecPath(path.join(SNAP_SCRIPTS, 'preflight.ts'), {}, 120000).catch((e) => ({ status: 'FAILED', reason: 'preflight_error', message: e.message }));
79
+ out.preflight = pre;
80
+
81
+ if (pre?.status === 'ACTION_REQUIRED' && pre.reason === 'not_signed_in') {
82
+ out.stage = 'blocked_on_sign_in';
83
+ out.signedIn = false;
84
+ out.next = [SIGN_IN_HELP, 'Then run this command again. Nothing else is missing on this computer.'];
85
+ return out;
86
+ }
87
+ if (pre?.status === 'ACTION_REQUIRED') {
88
+ out.stage = 'action_required';
89
+ out.issues.push(...(pre.issues?.map((i) => i.message) ?? [pre.message]).filter(Boolean));
90
+ out.next = ['Snap needs these before the Lens can be packaged. Fix them in Lens Studio, then run this again.'];
91
+ return out;
92
+ }
93
+ if (pre?.status !== 'READY') {
94
+ out.stage = 'preflight_failed';
95
+ out.issues.push(pre?.message || 'Snap\'s preflight did not return READY.');
96
+ return out;
97
+ }
98
+
99
+ out.ready = true;
100
+ out.signedIn = true;
101
+ out.packageId = pre.packageId;
102
+
103
+ // Snap's own exporter. Produces <project>/.export/<name>.spk, signed with the production key.
104
+ log('exporting the SnapOS Package');
105
+ out.stage = 'package';
106
+ const pkg = await bridge.eecPath(path.join(SNAP_SCRIPTS, 'package.ts'), {}, 300000);
107
+ out.package = pkg;
108
+ if (pkg?.status && pkg.status !== 'PACKAGED' && pkg.status !== 'READY') {
109
+ out.issues.push(pkg.message || 'The SnapOS Package export did not complete.');
110
+ return out;
111
+ }
112
+ out.spkPath = pkg?.spkPath || pkg?.packagePath || null;
113
+ out.checksum = pkg?.spkChecksum || pkg?.checksum || null;
114
+ out.bytes = pkg?.packageBytes ?? (out.spkPath && fs.existsSync(out.spkPath) ? fs.statSync(out.spkPath).size : null);
115
+ out.ok = !!out.spkPath;
116
+ out.stage = out.ok ? 'packaged' : 'package_failed';
117
+ out.next = out.ok
118
+ ? ['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.']
119
+ : ['The export did not produce a package. Read the message above.'];
120
+ return out;
121
+ }
122
+
123
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(HERE, 'publish.mjs')) {
124
+ const r = await publish(process.argv[2], { log: (m) => console.log(' ' + m) });
125
+ console.log(JSON.stringify(r, null, 2));
126
+ process.exitCode = r.ok ? 0 : r.stage === 'blocked_on_sign_in' ? 3 : 1;
127
+ }