faceless-cli 1.1.8 → 1.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faceless-cli",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "CLI for Faceless.so: create AI faceless videos, run automated series and publish to YouTube, TikTok, Instagram and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "generatedFrom": "src/backend/api/v1/spec/registry.mjs",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "baseUrl": "https://faceless.so/api/v1",
5
5
  "operations": [
6
6
  {
package/src/oauth.mjs CHANGED
@@ -73,6 +73,111 @@ async function allScopes(site) {
73
73
  return ""; // absent scope -> server's read-only default
74
74
  }
75
75
 
76
+ /**
77
+ * The page the loopback listener serves after the consent redirect.
78
+ *
79
+ * Three rules, each learned from the first version being wrong:
80
+ * - It must not claim success before it is true. "Logged in" was sent before the error/state
81
+ * params were even read, so DENYING consent still showed "Logged in", and even a real approval
82
+ * has only produced a code at this point - the exchange can still fail in the terminal.
83
+ * - It must scrub itself from history. The redirect lands with ?code=... in the URL, and the
84
+ * browser stores that in history/autocomplete; history.replaceState drops the query so a
85
+ * credential-bearing URL is never retained. The code is never echoed into the HTML either.
86
+ * - It should look like ours. A bare unstyled heading at a raw IP reads as broken or phishy.
87
+ */
88
+ export const callbackHtml = ({ ok, detail = "", site = "https://faceless.so" }) => `<!doctype html>
89
+ <html lang="en">
90
+ <head>
91
+ <meta charset="utf-8" />
92
+ <title>Faceless CLI</title>
93
+ <meta name="robots" content="noindex" />
94
+ <script>history.replaceState(null, "", "/callback");</script>
95
+ <style>
96
+ /* The consent page's visual shell (src/pages/oauth2/authorize.jsx, which itself mirrors /login),
97
+ replicated with the app's real tokens because this page is served from the CLI's loopback
98
+ listener where Tailwind and React do not exist: #060607 canvas, orbiting-star canvas at half
99
+ opacity, two blurred glow orbs (primary-600 / info-500), the .fa-grain noise overlay, the
100
+ top-left logo, and the #0A0A0D hairline card. This page renders one second after the consent
101
+ screen; any style break between them reads as leaving the product. */
102
+ * { box-sizing: border-box; }
103
+ body { margin: 0; min-height: 100vh; background: #060607; color: #fff;
104
+ font: 16px/1.6 Inter, -apple-system, "Segoe UI", Roboto, sans-serif; }
105
+ .ambient { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
106
+ .stars { position: absolute; inset: 0; opacity: 0.5; }
107
+ .orb-a { position: absolute; top: -160px; right: 8%; width: 480px; height: 480px;
108
+ border-radius: 50%; background: rgba(127, 20, 255, 0.08); filter: blur(160px); }
109
+ .orb-b { position: absolute; bottom: -10%; left: -6%; width: 420px; height: 420px;
110
+ border-radius: 50%; background: rgba(48, 180, 255, 0.04); filter: blur(130px); }
111
+ .grain { position: absolute; inset: 0; opacity: 0.04;
112
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"); }
113
+ .logo { position: absolute; left: 2.5rem; top: 2rem; z-index: 10; }
114
+ .logo img { display: block; width: 120px; height: auto; }
115
+ .shell { position: relative; z-index: 10; min-height: 100vh; display: flex;
116
+ align-items: center; justify-content: center; padding: 2.5rem 1rem; }
117
+ .card { position: relative; overflow: hidden; width: 100%; max-width: 420px; padding: 1.5rem 1.25rem;
118
+ background: #0A0A0D; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 1.5rem; }
119
+ .hairline { pointer-events: none; position: absolute; left: 2.5rem; right: 2.5rem; top: 0; height: 1px;
120
+ background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2), transparent); }
121
+ h1 { margin: 0 0 0.35rem; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; color: #fff; }
122
+ .fail h1 { color: #f87171; }
123
+ p { margin: 0; font-size: 13px; line-height: 1.55; color: #AEAEB2; }
124
+ .hint { margin-top: 1rem; font-size: 12px; color: #8E8E93; }
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <div class="ambient" aria-hidden="true">
129
+ <canvas class="stars" id="stars"></canvas>
130
+ <div class="orb-a"></div>
131
+ <div class="orb-b"></div>
132
+ <div class="grain"></div>
133
+ </div>
134
+ <div class="logo"><img src="${site}/faceless-logo.png" alt="Faceless" /></div>
135
+ <div class="shell">
136
+ <main class="card ${ok ? "" : "fail"}">
137
+ <div class="hairline"></div>
138
+ <h1>${ok ? "Authorization received" : "Authorization failed"}</h1>
139
+ <p>${ok ? "You can close this tab. The login finishes in your terminal." : `${detail} See your terminal for details.`}</p>
140
+ <p class="hint">${ok ? "This window holds no credentials and is safe to close." : "You can retry with `faceless login`."}</p>
141
+ </main>
142
+ </div>
143
+ <script>
144
+ // Compact port of the consent page's StarryBackground: stars slowly orbiting a centre point.
145
+ (function () {
146
+ var canvas = document.getElementById("stars");
147
+ var ctx = canvas.getContext("2d");
148
+ canvas.width = window.innerWidth;
149
+ canvas.height = window.innerHeight;
150
+ var cx = canvas.width / 2, cy = canvas.height / 2;
151
+ var stars = [];
152
+ for (var i = 0; i < 140; i++) {
153
+ stars.push({
154
+ radius: Math.random() * 1.5,
155
+ angle: Math.random() * Math.PI * 2,
156
+ orbit: Math.random() * (canvas.width / 2) + 100,
157
+ speed: Math.random() * 0.001 + 0.0005,
158
+ });
159
+ }
160
+ (function animate() {
161
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
162
+ ctx.fillStyle = "#fff";
163
+ for (var j = 0; j < stars.length; j++) {
164
+ var s = stars[j];
165
+ s.angle += s.speed;
166
+ var x = cx + Math.cos(s.angle) * s.orbit;
167
+ var y = cy + Math.sin(s.angle) * s.orbit;
168
+ ctx.globalAlpha = 0.9 - Math.min(0.7, s.orbit / canvas.width);
169
+ ctx.beginPath();
170
+ ctx.arc(x, y, s.radius, 0, Math.PI * 2);
171
+ ctx.fill();
172
+ }
173
+ ctx.globalAlpha = 1;
174
+ requestAnimationFrame(animate);
175
+ })();
176
+ })();
177
+ </script>
178
+ </body>
179
+ </html>`;
180
+
76
181
  const openBrowser = (url) => {
77
182
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
78
183
  execFile(command, [url], () => {});
@@ -109,13 +214,33 @@ export async function loginWithOAuth({ baseUrl, log = () => {} }) {
109
214
  res.writeHead(404).end();
110
215
  return;
111
216
  }
112
- res.writeHead(200, { "Content-Type": "text/html" }).end("<h3>Logged in. You can close this tab and return to the terminal.</h3>");
217
+ // Validate BEFORE responding, so the page tells the truth: the first version sent
218
+ // "Logged in" unconditionally, including when the user had just clicked Deny.
219
+ const errorParam = url.searchParams.get("error");
220
+ const stateOk = url.searchParams.get("state") === state;
221
+ const code = url.searchParams.get("code");
222
+ const ok = stateOk && !errorParam && Boolean(code);
223
+ const detail = !stateOk
224
+ ? "The redirect did not match this login attempt."
225
+ : errorParam === "access_denied"
226
+ ? "You denied the request."
227
+ : errorParam
228
+ ? `The server rejected the request (${errorParam}).`
229
+ : "No authorization code arrived.";
230
+ res.writeHead(ok ? 200 : 400, {
231
+ "Content-Type": "text/html; charset=utf-8",
232
+ // The redirect URL carried a credential (the code); nothing about it may be cached.
233
+ "Cache-Control": "no-store",
234
+ "Referrer-Policy": "no-referrer",
235
+ });
236
+ res.end(callbackHtml({ ok, detail, site }));
113
237
  clearTimeout(timer);
114
238
  server.close();
115
- if (url.searchParams.get("state") !== state) return reject(new CliError("unauthorized", "State mismatch in OAuth redirect."));
116
- const error = url.searchParams.get("error");
117
- if (error) return reject(new CliError("unauthorized", `Authorization was ${error === "access_denied" ? "denied" : `rejected: ${error}`}.`));
118
- resolve(url.searchParams.get("code"));
239
+ if (!stateOk) return reject(new CliError("unauthorized", "State mismatch in OAuth redirect."));
240
+ if (errorParam)
241
+ return reject(new CliError("unauthorized", `Authorization was ${errorParam === "access_denied" ? "denied" : `rejected: ${errorParam}`}.`));
242
+ if (!code) return reject(new CliError("unauthorized", "Redirect arrived without an authorization code."));
243
+ resolve(code);
119
244
  });
120
245
  server.on("error", (err) => {
121
246
  clearTimeout(timer);