ai-spend-agent 0.9.0 → 0.9.2
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/guidedPrompt.d.ts +2 -27
- package/dist/guidedPrompt.js +5 -282
- package/dist/improveFlow.d.ts +34 -4
- package/dist/improveFlow.js +44 -16
- package/dist/index.d.ts +11 -0
- package/dist/index.js +972 -83
- package/dist/projectAccountabilityState.d.ts +6 -7
- package/dist/projectAccountabilityState.js +6 -15
- package/dist/signup.d.ts +272 -0
- package/dist/signup.js +681 -0
- package/dist/statuslineRuntime.js +3 -1
- package/dist/telemetry.d.ts +149 -0
- package/dist/telemetry.js +390 -0
- package/package.json +4 -3
package/dist/signup.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { renderDrainNotice } from "./guidedPrompt.js";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* CLI email capture — the launch-list signup lane (v0.9.2).
|
|
7
|
+
*
|
|
8
|
+
* Design: docs/qa-handoff/CLI_CAPTURE_DESIGN.md (see its dated placement
|
|
9
|
+
* addendum). QA verdict (its B/M fixes are mandatory):
|
|
10
|
+
* docs/qa-handoff/CLI_CAPTURE_QA_VERDICT.md.
|
|
11
|
+
*
|
|
12
|
+
* Placement (founder decision 2026-08-24): the ONE ask runs PRE-RECEIPT,
|
|
13
|
+
* DURING the first evidence scan — it fills the first-run wait instead of
|
|
14
|
+
* adding one. The receipt renders when both the human's answer (or skip /
|
|
15
|
+
* timeout / Ctrl-C) and the pipeline resolve; consent, when an email was
|
|
16
|
+
* typed, renders strictly AFTER the receipt.
|
|
17
|
+
*
|
|
18
|
+
* Hard rules encoded here:
|
|
19
|
+
* - The payload is EXACTLY {email, ref} — the deployed asktilden.com route
|
|
20
|
+
* reads `ref` (not `sourceRef`) and normalizes anything failing
|
|
21
|
+
* /^[a-z0-9][a-z0-9_-]{0,63}$/ (dots included) to "direct" (verdict B1).
|
|
22
|
+
* - Consent shows the literal payload JSON; the sent line claims only the
|
|
23
|
+
* payload layer, never "nothing else left this machine" (verdict B2).
|
|
24
|
+
* - One POST attempt, 3s timeout, no retry, no queue, and the typed email is
|
|
25
|
+
* never persisted on failure.
|
|
26
|
+
* - Every read is timeout-guarded and can never eat a skip: a timeout or
|
|
27
|
+
* Ctrl-C records NO lifetime skip (verdict M3).
|
|
28
|
+
* - Skip = two empty Enters with one declarative nudge between them; the
|
|
29
|
+
* completed pair counts as ONE lifetime skip. This is the founder-approved
|
|
30
|
+
* ceiling: no further skip friction may be added, and removal of skip is
|
|
31
|
+
* refused by design.
|
|
32
|
+
* - Never-ask state lives in ~/.aibill/signup.json (the existing home dir —
|
|
33
|
+
* verdict M5) and fails CLOSED to never-ask on corrupt/readonly state.
|
|
34
|
+
*/
|
|
35
|
+
export const waitlistUrl = "https://asktilden.com/api/waitlist";
|
|
36
|
+
// Server-parity email rules (apps/web/app/api/waitlist/route.ts): trim,
|
|
37
|
+
// lowercase, <=254 chars, pragmatic shape check. The CLI additionally rejects
|
|
38
|
+
// control bytes (terminal-escape injection into the echoed consent line).
|
|
39
|
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
40
|
+
const controlBytePattern = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
41
|
+
// The deployed route's ref contract: dot-free, else attribution silently
|
|
42
|
+
// becomes "direct" (verdict B1). Both patterns are pinned by tests.
|
|
43
|
+
export const deployedRefPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
44
|
+
const refTagPattern = /^[a-z0-9-]{1,24}$/;
|
|
45
|
+
export function normalizeWaitlistEmail(raw) {
|
|
46
|
+
const email = raw.trim().toLowerCase();
|
|
47
|
+
if (!email || email.length > 254)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (controlBytePattern.test(email))
|
|
50
|
+
return undefined;
|
|
51
|
+
if (!emailPattern.test(email))
|
|
52
|
+
return undefined;
|
|
53
|
+
return email;
|
|
54
|
+
}
|
|
55
|
+
/** `--ref` allowlist: lowercase alphanumerics and dashes, 1–24 chars. */
|
|
56
|
+
export function sanitizeSignupRefTag(raw) {
|
|
57
|
+
const tag = raw.trim().toLowerCase();
|
|
58
|
+
return refTagPattern.test(tag) ? tag : undefined;
|
|
59
|
+
}
|
|
60
|
+
export function buildWaitlistRef(surface, tag) {
|
|
61
|
+
const ref = tag === undefined ? `cli-${surface}` : `cli-${surface}-${tag}`;
|
|
62
|
+
if (!deployedRefPattern.test(ref)) {
|
|
63
|
+
// Structurally unreachable (surface enum + tag allowlist), kept as a
|
|
64
|
+
// fail-closed guard so a future edit cannot ship a ref the deployed
|
|
65
|
+
// route would rewrite to "direct".
|
|
66
|
+
throw new Error("signup ref failed the deployed route contract");
|
|
67
|
+
}
|
|
68
|
+
return ref;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The exact bytes sent — key order pinned. Payload creep (adding os/plan/
|
|
72
|
+
* version data, or stuffing values into ref) fails the CI creep-guard test;
|
|
73
|
+
* see docs/qa-handoff/CLI_CAPTURE_DESIGN.md §3c before touching this.
|
|
74
|
+
*/
|
|
75
|
+
export function serializeWaitlistPayload(payload) {
|
|
76
|
+
return JSON.stringify({ email: payload.email, ref: payload.ref });
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Single POST, 3s timeout, one attempt, no retry, no queue. The response
|
|
80
|
+
* body is never printed (it could be an HTML error page); only the status
|
|
81
|
+
* code is mapped. Headers are the full explicit set this request adds —
|
|
82
|
+
* pinned by test so header creep fails CI too.
|
|
83
|
+
*/
|
|
84
|
+
export async function postWaitlistSignup(payload, options = {}) {
|
|
85
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetchImpl(waitlistUrl, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json", "user-agent": "aibill-cli" },
|
|
90
|
+
body: serializeWaitlistPayload(payload),
|
|
91
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 3_000)
|
|
92
|
+
});
|
|
93
|
+
// 201 covers duplicates too — the route maps the unique-email conflict
|
|
94
|
+
// to 201, so signup is idempotent server-side.
|
|
95
|
+
if (response.status === 201)
|
|
96
|
+
return "sent";
|
|
97
|
+
if (response.status === 422)
|
|
98
|
+
return "invalid_email";
|
|
99
|
+
if (response.status === 429)
|
|
100
|
+
return "rate_limited";
|
|
101
|
+
return "unreachable";
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return "unreachable";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Small static blocklist of throwaway-inbox domains (exact match only). */
|
|
108
|
+
export const disposableEmailDomains = new Set([
|
|
109
|
+
"mailinator.com",
|
|
110
|
+
"guerrillamail.com",
|
|
111
|
+
"10minutemail.com",
|
|
112
|
+
"tempmail.com",
|
|
113
|
+
"temp-mail.org",
|
|
114
|
+
"yopmail.com",
|
|
115
|
+
"sharklasers.com",
|
|
116
|
+
"grr.la",
|
|
117
|
+
"trashmail.com",
|
|
118
|
+
"getnada.com",
|
|
119
|
+
"dispostable.com",
|
|
120
|
+
"maildrop.cc",
|
|
121
|
+
"mintemail.com",
|
|
122
|
+
"throwawaymail.com",
|
|
123
|
+
"fakeinbox.com",
|
|
124
|
+
"mailnesia.com",
|
|
125
|
+
"spamgourmet.com",
|
|
126
|
+
"mytemp.email",
|
|
127
|
+
"tempinbox.com",
|
|
128
|
+
"emailondeck.com"
|
|
129
|
+
]);
|
|
130
|
+
/** Exact or subdomain match against the blocklist (QA m5). */
|
|
131
|
+
export function isDisposableEmailDomain(domain) {
|
|
132
|
+
if (disposableEmailDomains.has(domain))
|
|
133
|
+
return true;
|
|
134
|
+
for (const blocked of disposableEmailDomains) {
|
|
135
|
+
if (domain.endsWith(`.${blocked}`))
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
async function defaultSignupDnsResolver() {
|
|
141
|
+
const dns = await import("node:dns/promises");
|
|
142
|
+
return { resolveMx: (domain) => dns.resolveMx(domain), resolve4: (domain) => dns.resolve4(domain) };
|
|
143
|
+
}
|
|
144
|
+
const dnsTimeoutMs = 1_500;
|
|
145
|
+
function isDefinitiveDnsMiss(error) {
|
|
146
|
+
const code = error?.code;
|
|
147
|
+
// EBADNAME: the resolver refused the name itself (invalid/homoglyph
|
|
148
|
+
// label) — as provably undeliverable as NXDOMAIN (QA m4).
|
|
149
|
+
return code === "ENOTFOUND" || code === "ENODATA" || code === "EBADNAME";
|
|
150
|
+
}
|
|
151
|
+
export async function assessEmailDeliverability(email, options = {}) {
|
|
152
|
+
const domain = email.slice(email.lastIndexOf("@") + 1);
|
|
153
|
+
if (isDisposableEmailDomain(domain))
|
|
154
|
+
return "disposable";
|
|
155
|
+
const resolver = options.resolver ?? await defaultSignupDnsResolver().catch(() => undefined);
|
|
156
|
+
if (!resolver)
|
|
157
|
+
return "ok";
|
|
158
|
+
// ONE shared deadline for the whole check (QA m3): the MX lookup and the
|
|
159
|
+
// A-record fallback split a single 1.5s budget instead of stacking two.
|
|
160
|
+
const deadlineAt = Date.now() + (options.timeoutMs ?? dnsTimeoutMs);
|
|
161
|
+
const withTimeout = async (work) => {
|
|
162
|
+
let timer;
|
|
163
|
+
try {
|
|
164
|
+
return await Promise.race([
|
|
165
|
+
work,
|
|
166
|
+
new Promise((_, reject) => {
|
|
167
|
+
timer = setTimeout(() => reject(new Error("dns timeout")), Math.max(1, deadlineAt - Date.now()));
|
|
168
|
+
timer.unref?.();
|
|
169
|
+
})
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
const records = await withTimeout(resolver.resolveMx(domain));
|
|
178
|
+
// RFC 7505 null MX ("." exchange) is an explicit refusal to receive.
|
|
179
|
+
const receiving = records.filter((record) => record.exchange !== "" && record.exchange !== ".");
|
|
180
|
+
if (receiving.length > 0)
|
|
181
|
+
return "ok";
|
|
182
|
+
if (records.length > 0)
|
|
183
|
+
return "no_mx";
|
|
184
|
+
// Zero MX records without an error: fall through to the A-record check.
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!isDefinitiveDnsMiss(error)) {
|
|
188
|
+
// Timeout / transport / resolver failure: NEVER block capture on DNS.
|
|
189
|
+
return "ok";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const addresses = await withTimeout(resolver.resolve4(domain));
|
|
194
|
+
return addresses.length > 0 ? "ok" : "no_mx";
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return isDefinitiveDnsMiss(error) ? "no_mx" : "ok";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Copy — declarative, ·-separated, promising ONLY what exists for the cli-*
|
|
202
|
+
// refs: the launch email + product updates (docs/EMAIL_SEND_POLICY.md
|
|
203
|
+
// anchors the promise per ref; verdict M1/M2). No savings claims, no
|
|
204
|
+
// urgency, no confirm-shaming, no beta/Workspace/weekly language.
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
export const signupCopy = {
|
|
207
|
+
/**
|
|
208
|
+
* Launch-week headline — TRUE only through launch day (Fri Aug 28); the
|
|
209
|
+
* date gate in signupAskBlockLines swaps in the evergreen fallback after
|
|
210
|
+
* signupLaunchWindowEndsAt so this copy can never go stale.
|
|
211
|
+
*/
|
|
212
|
+
askHeadlineLaunch: [
|
|
213
|
+
"aibill launches Friday with Star.fun.",
|
|
214
|
+
"Get the launch email + what ships next:"
|
|
215
|
+
],
|
|
216
|
+
askHeadlineFallback: ["Get product updates:"],
|
|
217
|
+
askFooter: "type your email, or press Enter to skip",
|
|
218
|
+
askPrompt: " > ",
|
|
219
|
+
waitLine: "reading your local AI evidence…",
|
|
220
|
+
/** Printed once if the scan finishes while the ask is still open. */
|
|
221
|
+
receiptReadyLine: "your receipt is ready — type your email, or press Enter to see it",
|
|
222
|
+
/** Printed after the FIRST empty Enter; the second empty Enter skips. */
|
|
223
|
+
skipNudgeLine: "one launch email · Enter again to skip, or type your email",
|
|
224
|
+
scopeLine: "used only for updates · never shared",
|
|
225
|
+
/** Printed on the first Ctrl-C at the ask (the scan continues; M2). */
|
|
226
|
+
interruptLine: "ok — skipped the ask · your receipt is still being read (Ctrl-C again to quit)",
|
|
227
|
+
/** Printed when ^C lands after the ask settled but before the receipt. */
|
|
228
|
+
interruptAfterAnswerLine: "still reading your evidence · Ctrl-C again to quit",
|
|
229
|
+
/** Printed when the re-prompt budget closes the ask (no skip consumed). */
|
|
230
|
+
budgetClosedLine: "moving on · npx aibill signup <email> anytime",
|
|
231
|
+
consentQuestion: (payloadJson) => `send ${payloadJson} → asktilden.com/api/waitlist? [y/N] `,
|
|
232
|
+
sentLine: "sent: exactly that JSON · nothing else in the payload",
|
|
233
|
+
neverLine: "ok — never asking again · npx aibill signup <email> if you change your mind",
|
|
234
|
+
invalidEmailLine: "that email did not validate — check it and retry",
|
|
235
|
+
noMxLine: "that domain can't receive email — check the spelling",
|
|
236
|
+
disposableLine: "use an address you actually check",
|
|
237
|
+
rateLimitedLine: "busy — try again in a minute, or join at https://asktilden.com",
|
|
238
|
+
unreachableLine: "could not reach asktilden.com — join at https://asktilden.com",
|
|
239
|
+
alreadyLine: "already on the list · nothing sent twice",
|
|
240
|
+
nothingSentLine: "nothing sent",
|
|
241
|
+
nonInteractiveLine: "signup needs an interactive terminal — or join at https://asktilden.com",
|
|
242
|
+
forgetLine: "local signup state cleared · to leave the list, reply to any email or write hello@asktilden.com",
|
|
243
|
+
// Static pointer lines (never interactive; see the capture design's
|
|
244
|
+
// moments map).
|
|
245
|
+
receiptPointer: "npx aibill signup <email> launch updates · optional · email only",
|
|
246
|
+
samplePointer: "launch updates: npx aibill signup <email> · optional · email only",
|
|
247
|
+
initPointer: "launch updates (optional): npx aibill signup <email>",
|
|
248
|
+
statuslinePointer: "launch updates: npx aibill signup <email> · optional"
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* End of the launch-week headline window (the Friday line is true only
|
|
252
|
+
* through launch day, Fri 2026-08-28). After this instant the ask block
|
|
253
|
+
* automatically uses the evergreen fallback headline.
|
|
254
|
+
*/
|
|
255
|
+
export const signupLaunchWindowEndsAt = "2026-08-29T00:00:00Z";
|
|
256
|
+
/**
|
|
257
|
+
* The standout ask block: full-width rule (the receipt's own rule
|
|
258
|
+
* convention), then the headline for the current window, then the skip
|
|
259
|
+
* footer. Text lines carry the receipt's two-space indent.
|
|
260
|
+
*/
|
|
261
|
+
export function signupAskBlockLines(now, width) {
|
|
262
|
+
const headline = now.getTime() < Date.parse(signupLaunchWindowEndsAt)
|
|
263
|
+
? signupCopy.askHeadlineLaunch
|
|
264
|
+
: signupCopy.askHeadlineFallback;
|
|
265
|
+
return [
|
|
266
|
+
"─".repeat(Math.max(8, width)),
|
|
267
|
+
...headline.map((line) => ` ${line}`),
|
|
268
|
+
` ${signupCopy.askFooter}`
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
/** Same clamp the receipt renderer applies (COLUMNS → tty → 72, 40..120). */
|
|
272
|
+
export function signupTerminalWidth() {
|
|
273
|
+
const envColumns = Number(process.env.COLUMNS);
|
|
274
|
+
const ttyColumns = process.stdout.columns;
|
|
275
|
+
const requested = Number.isFinite(envColumns) && envColumns > 0
|
|
276
|
+
? envColumns
|
|
277
|
+
: Number.isFinite(ttyColumns) && (ttyColumns ?? 0) > 0
|
|
278
|
+
? ttyColumns
|
|
279
|
+
: 72;
|
|
280
|
+
return Math.max(40, Math.min(120, Math.floor(requested)));
|
|
281
|
+
}
|
|
282
|
+
export function signupStateFilePath(homeDirectory) {
|
|
283
|
+
return join(homeDirectory ?? homedir(), ".aibill", "signup.json");
|
|
284
|
+
}
|
|
285
|
+
function isSignupState(value) {
|
|
286
|
+
if (typeof value !== "object" || value === null)
|
|
287
|
+
return false;
|
|
288
|
+
const state = value;
|
|
289
|
+
return state.version === 1 &&
|
|
290
|
+
(state.status === "subscribed" || state.status === "never" || state.status === "deferred") &&
|
|
291
|
+
typeof state.askCount === "number" && Number.isInteger(state.askCount) && state.askCount >= 0 &&
|
|
292
|
+
(state.stampCount === undefined ||
|
|
293
|
+
(typeof state.stampCount === "number" && Number.isInteger(state.stampCount) && state.stampCount >= 0)) &&
|
|
294
|
+
(state.lastAskedAt === undefined || typeof state.lastAskedAt === "string") &&
|
|
295
|
+
(state.email === undefined || typeof state.email === "string");
|
|
296
|
+
}
|
|
297
|
+
export async function readSignupState(filePath) {
|
|
298
|
+
let contents;
|
|
299
|
+
try {
|
|
300
|
+
contents = await readFile(filePath, "utf8");
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
304
|
+
return { kind: "fresh" };
|
|
305
|
+
}
|
|
306
|
+
// Unreadable (permissions, not a file, …): fail closed to never-ask.
|
|
307
|
+
return { kind: "unreadable" };
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const parsed = JSON.parse(contents);
|
|
311
|
+
if (!isSignupState(parsed))
|
|
312
|
+
return { kind: "unreadable" };
|
|
313
|
+
return { kind: "ok", state: parsed };
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Corrupt state must never cause re-nagging: fail closed.
|
|
317
|
+
return { kind: "unreadable" };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Atomic-ish write; returns false instead of throwing so callers fail closed. */
|
|
321
|
+
export async function writeSignupState(filePath, state) {
|
|
322
|
+
try {
|
|
323
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
324
|
+
const temporaryPath = `${filePath}.tmp`;
|
|
325
|
+
await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
326
|
+
await rename(temporaryPath, filePath);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
export async function clearSignupState(filePath) {
|
|
334
|
+
try {
|
|
335
|
+
await rm(filePath, { force: true });
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const minimumMsBetweenAsks = 7 * 24 * 60 * 60 * 1_000;
|
|
343
|
+
/** Hard lifetime ceiling on ask openings, decided answer or not (QA m10). */
|
|
344
|
+
export const maximumLifetimeAskStamps = 6;
|
|
345
|
+
/**
|
|
346
|
+
* Whether the ONE ask may run. Fails closed on unreadable state; a
|
|
347
|
+
* future-dated lastAskedAt (clock moved back) counts as recently asked,
|
|
348
|
+
* never as a reason to re-ask.
|
|
349
|
+
*/
|
|
350
|
+
export function signupAskAllowed(read, now) {
|
|
351
|
+
if (read.kind === "unreadable")
|
|
352
|
+
return false;
|
|
353
|
+
if (read.kind === "fresh")
|
|
354
|
+
return true;
|
|
355
|
+
const state = read.state;
|
|
356
|
+
if (state.status === "subscribed" || state.status === "never")
|
|
357
|
+
return false;
|
|
358
|
+
if (state.askCount >= 2)
|
|
359
|
+
return false;
|
|
360
|
+
if ((state.stampCount ?? 0) >= maximumLifetimeAskStamps)
|
|
361
|
+
return false;
|
|
362
|
+
if (state.lastAskedAt !== undefined) {
|
|
363
|
+
const askedAt = Date.parse(state.lastAskedAt);
|
|
364
|
+
if (!Number.isFinite(askedAt))
|
|
365
|
+
return false;
|
|
366
|
+
const age = now.getTime() - askedAt;
|
|
367
|
+
if (age < minimumMsBetweenAsks)
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
export const signupAskTimeoutMs = 30_000;
|
|
373
|
+
/**
|
|
374
|
+
* Opens the ONE ask, or returns undefined (with zero output) when state
|
|
375
|
+
* disallows it — subsequent runs stay byte-identical to the fast path.
|
|
376
|
+
*
|
|
377
|
+
* Decision ledger (unchanged semantics, earlier placement):
|
|
378
|
+
* - timeout / Ctrl-C → no decision: lastAskedAt stamped, NO skip
|
|
379
|
+
* - Enter, then Enter again → ONE lifetime skip (nudge line between)
|
|
380
|
+
* - n / never → never ask again
|
|
381
|
+
* - typed input → email path (format + deliverability checks
|
|
382
|
+
* re-prompt without consuming skips)
|
|
383
|
+
* The consent step (literal payload JSON → y/N → single POST) runs strictly
|
|
384
|
+
* AFTER the receipt via runSignupConsentAfterReceipt.
|
|
385
|
+
*/
|
|
386
|
+
export async function openPreReceiptSignupAsk(options) {
|
|
387
|
+
const now = options.now ?? (() => new Date());
|
|
388
|
+
const read = await readSignupState(options.stateFilePath);
|
|
389
|
+
if (!signupAskAllowed(read, now()))
|
|
390
|
+
return undefined;
|
|
391
|
+
const base = read.kind === "ok"
|
|
392
|
+
? read.state
|
|
393
|
+
: { version: 1, status: "deferred", askCount: 0 };
|
|
394
|
+
// Stamp the ask BEFORE prompting. If home state cannot be written the ask
|
|
395
|
+
// never shows (readonly home must not become an every-run nag: the decision
|
|
396
|
+
// could never be persisted — fail closed to never-ask).
|
|
397
|
+
const stamped = {
|
|
398
|
+
...base,
|
|
399
|
+
status: "deferred",
|
|
400
|
+
stampCount: (base.stampCount ?? 0) + 1,
|
|
401
|
+
lastAskedAt: now().toISOString()
|
|
402
|
+
};
|
|
403
|
+
if (!await writeSignupState(options.stateFilePath, stamped))
|
|
404
|
+
return undefined;
|
|
405
|
+
let settled = false;
|
|
406
|
+
const io = options.io;
|
|
407
|
+
const write = (line) => io.write(line);
|
|
408
|
+
const outcome = (async () => {
|
|
409
|
+
for (const line of options.preambleLines ?? [])
|
|
410
|
+
write(line);
|
|
411
|
+
write("");
|
|
412
|
+
for (const line of signupAskBlockLines(now(), options.width ?? 72))
|
|
413
|
+
write(line);
|
|
414
|
+
let enterNudgeShown = false;
|
|
415
|
+
// Bounded re-prompt budget: format/deliverability rejections re-prompt
|
|
416
|
+
// without consuming skips, but an adversarial or hopeless session ends
|
|
417
|
+
// silently (no skip consumed) instead of looping forever.
|
|
418
|
+
for (let rejections = 0; rejections < 6; rejections += 1) {
|
|
419
|
+
const answer = await io.question(signupCopy.askPrompt, signupAskTimeoutMs);
|
|
420
|
+
if (answer === undefined) {
|
|
421
|
+
// Inactivity timeout or Ctrl-C: no decision, no lifetime skip
|
|
422
|
+
// (verdict M3) — the lastAskedAt stamp alone throttles re-asks.
|
|
423
|
+
return { kind: "skipped" };
|
|
424
|
+
}
|
|
425
|
+
const trimmed = answer.trim();
|
|
426
|
+
if (trimmed === "") {
|
|
427
|
+
if (!enterNudgeShown) {
|
|
428
|
+
// Founder-approved ceiling: exactly one declarative nudge, then
|
|
429
|
+
// the second empty Enter completes the skip. Never more.
|
|
430
|
+
enterNudgeShown = true;
|
|
431
|
+
write(` ${signupCopy.skipNudgeLine}`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
await writeSignupState(options.stateFilePath, { ...stamped, askCount: stamped.askCount + 1 });
|
|
435
|
+
return { kind: "skipped" };
|
|
436
|
+
}
|
|
437
|
+
const lowered = trimmed.toLowerCase();
|
|
438
|
+
if (lowered === "n" || lowered === "never") {
|
|
439
|
+
await writeSignupState(options.stateFilePath, { ...stamped, status: "never" });
|
|
440
|
+
write(` ${signupCopy.neverLine}`);
|
|
441
|
+
return { kind: "skipped" };
|
|
442
|
+
}
|
|
443
|
+
const email = normalizeWaitlistEmail(trimmed);
|
|
444
|
+
if (email === undefined) {
|
|
445
|
+
write(` ${signupCopy.invalidEmailLine}`);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const deliverability = await assessEmailDeliverability(email, {
|
|
449
|
+
...(options.dnsResolver ? { resolver: options.dnsResolver } : {})
|
|
450
|
+
});
|
|
451
|
+
if (deliverability === "no_mx") {
|
|
452
|
+
write(` ${signupCopy.noMxLine}`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (deliverability === "disposable") {
|
|
456
|
+
write(` ${signupCopy.disposableLine}`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
return { kind: "email", payload: { email, ref: buildWaitlistRef("receipt") }, stamped };
|
|
460
|
+
}
|
|
461
|
+
// Re-prompt budget exhausted: close the surface honestly instead of a
|
|
462
|
+
// silent dead prompt (QA m2). No skip is consumed.
|
|
463
|
+
write(` ${signupCopy.budgetClosedLine}`);
|
|
464
|
+
return { kind: "skipped" };
|
|
465
|
+
})().finally(() => {
|
|
466
|
+
settled = true;
|
|
467
|
+
});
|
|
468
|
+
return {
|
|
469
|
+
outcome,
|
|
470
|
+
notifyReceiptReady: () => {
|
|
471
|
+
if (settled)
|
|
472
|
+
return;
|
|
473
|
+
io.write(` ${signupCopy.receiptReadyLine}`);
|
|
474
|
+
io.writeRaw?.(signupCopy.askPrompt);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The consent step, strictly AFTER the receipt has printed: scope line, the
|
|
480
|
+
* literal payload JSON, a typed y — then ONE POST. Failures never persist,
|
|
481
|
+
* retry, or queue the typed email.
|
|
482
|
+
*/
|
|
483
|
+
export async function runSignupConsentAfterReceipt(options) {
|
|
484
|
+
const { io, stamped } = options;
|
|
485
|
+
io.write("");
|
|
486
|
+
io.write(signupCopy.scopeLine);
|
|
487
|
+
const consent = await io.question(signupCopy.consentQuestion(serializeWaitlistPayload(options.payload)), signupAskTimeoutMs);
|
|
488
|
+
if (consent === undefined)
|
|
489
|
+
return;
|
|
490
|
+
const consentAnswer = consent.trim().toLowerCase();
|
|
491
|
+
if (consentAnswer !== "y" && consentAnswer !== "yes") {
|
|
492
|
+
await writeSignupState(options.stateFilePath, { ...stamped, askCount: stamped.askCount + 1 });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const outcome = await postWaitlistSignup(options.payload, {
|
|
496
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {})
|
|
497
|
+
});
|
|
498
|
+
if (outcome === "sent") {
|
|
499
|
+
await writeSignupState(options.stateFilePath, {
|
|
500
|
+
...stamped,
|
|
501
|
+
status: "subscribed",
|
|
502
|
+
email: options.payload.email
|
|
503
|
+
});
|
|
504
|
+
io.write(signupCopy.sentLine);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
// Failure: the typed email is never persisted, retried, or queued.
|
|
508
|
+
await writeSignupState(options.stateFilePath, stamped);
|
|
509
|
+
io.write(outcome === "invalid_email"
|
|
510
|
+
? signupCopy.invalidEmailLine
|
|
511
|
+
: outcome === "rate_limited"
|
|
512
|
+
? signupCopy.rateLimitedLine
|
|
513
|
+
: signupCopy.unreachableLine);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Which argv shapes qualify for the during-scan ask: the real receipt path
|
|
517
|
+
* only — the default/quickstart command (flag-form included) — and never
|
|
518
|
+
* sample, drill-downs, JSON, help/version, or any named command (improve,
|
|
519
|
+
* report, init, …).
|
|
520
|
+
*/
|
|
521
|
+
export function qualifiesForPreReceiptSignupAsk(argv) {
|
|
522
|
+
if (argv.includes("--sample") || argv.includes("--group-by") || argv.includes("--json"))
|
|
523
|
+
return false;
|
|
524
|
+
if (argv.includes("--help") || argv.includes("-h") || argv.includes("--version") || argv.includes("-v"))
|
|
525
|
+
return false;
|
|
526
|
+
const command = argv[0];
|
|
527
|
+
return command === undefined || command === "quickstart" || command === "demo" || command.startsWith("--");
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Concurrency shell: run the evidence pipeline and the open ask together.
|
|
531
|
+
* The receipt renders only after BOTH resolve; a pipeline error is captured
|
|
532
|
+
* (never an unhandled rejection) and still waits for the bounded ask, so a
|
|
533
|
+
* prompt can never hang over a dead pipeline longer than its own timeout.
|
|
534
|
+
* If the pipeline settles first, the session gets the one ready-nudge.
|
|
535
|
+
*/
|
|
536
|
+
export async function orchestratePreReceiptAsk(input) {
|
|
537
|
+
const settledPipeline = input.runPipeline().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
538
|
+
const session = input.session;
|
|
539
|
+
if (!session) {
|
|
540
|
+
return { pipeline: await settledPipeline, outcome: { kind: "no_ask" } };
|
|
541
|
+
}
|
|
542
|
+
let askSettled = false;
|
|
543
|
+
const trackedOutcome = session.outcome.then((value) => {
|
|
544
|
+
askSettled = true;
|
|
545
|
+
return value;
|
|
546
|
+
});
|
|
547
|
+
void settledPipeline.then((settled) => {
|
|
548
|
+
// The nudge announces a READY receipt — never fire it over an error
|
|
549
|
+
// (QA m1); the error voice follows once the bounded ask resolves.
|
|
550
|
+
if (settled.ok && !askSettled)
|
|
551
|
+
session.notifyReceiptReady();
|
|
552
|
+
});
|
|
553
|
+
const [pipeline, outcome] = await Promise.all([settledPipeline, trackedOutcome]);
|
|
554
|
+
return { pipeline, outcome };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Opens the during-scan ask on the real terminal: its own readline with
|
|
558
|
+
* per-read AbortSignal timeouts. The timeout race can never eat a typed
|
|
559
|
+
* skip — either the line resolved first (the skip is recorded) or the abort
|
|
560
|
+
* won and NOTHING is recorded; the wrong decision is unrepresentable.
|
|
561
|
+
* Ctrl-C cancels only the ask (no skip consumed, state already stamped);
|
|
562
|
+
* the receipt still renders. Returns undefined — with zero output — when
|
|
563
|
+
* signup state disallows the ask.
|
|
564
|
+
*/
|
|
565
|
+
export async function openPreReceiptSignupAskInTerminal() {
|
|
566
|
+
try {
|
|
567
|
+
const { createInterface } = await import("node:readline/promises");
|
|
568
|
+
const lineInterface = createInterface({ input: process.stdin, output: process.stdout });
|
|
569
|
+
let interrupted = false;
|
|
570
|
+
let abortedRead = false;
|
|
571
|
+
// Ctrl-C must SETTLE the pending read (closing the interface alone
|
|
572
|
+
// leaves the waiter unsettled, draining the loop before the receipt
|
|
573
|
+
// prints — the ask would eat the whole run). The interrupt closes the
|
|
574
|
+
// read deterministically; the ask resolves as a no-decision, one ack
|
|
575
|
+
// line explains that the scan continues (QA M2), and the receipt still
|
|
576
|
+
// renders.
|
|
577
|
+
let waiter;
|
|
578
|
+
const settleWaiter = (line) => {
|
|
579
|
+
const settle = waiter;
|
|
580
|
+
waiter = undefined;
|
|
581
|
+
if (settle)
|
|
582
|
+
settle(line);
|
|
583
|
+
};
|
|
584
|
+
// QA M4: lines that arrive while no read is armed (paste bursts landing
|
|
585
|
+
// in the gaps between questions) are buffered and then DISCARDED with
|
|
586
|
+
// the guided engine's drain notice when the next read arms — they are
|
|
587
|
+
// never used as answers, so pasted input can neither pre-answer the
|
|
588
|
+
// consent step nor strand the user in 30s of silent dead air.
|
|
589
|
+
const strayLines = [];
|
|
590
|
+
lineInterface.on("line", (line) => {
|
|
591
|
+
if (waiter)
|
|
592
|
+
settleWaiter(line);
|
|
593
|
+
else
|
|
594
|
+
strayLines.push(line);
|
|
595
|
+
});
|
|
596
|
+
lineInterface.on("close", () => {
|
|
597
|
+
settleWaiter(undefined);
|
|
598
|
+
});
|
|
599
|
+
lineInterface.on("SIGINT", () => {
|
|
600
|
+
interrupted = true;
|
|
601
|
+
if (waiter) {
|
|
602
|
+
// A read was pending: skip the ask, keep the scan (QA M2).
|
|
603
|
+
process.stdout.write(`\n ${signupCopy.interruptLine}\n`);
|
|
604
|
+
settleWaiter(undefined);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
// No read pending (already answered / consent done): readline was
|
|
608
|
+
// swallowing the ^C — say so and stand aside so the NEXT ^C gets
|
|
609
|
+
// the default kill behavior.
|
|
610
|
+
process.stdout.write(`\n ${signupCopy.interruptAfterAnswerLine}\n`);
|
|
611
|
+
}
|
|
612
|
+
lineInterface.close();
|
|
613
|
+
});
|
|
614
|
+
const io = {
|
|
615
|
+
question: async (query, timeoutMs) => {
|
|
616
|
+
if (interrupted)
|
|
617
|
+
return undefined;
|
|
618
|
+
if (strayLines.length > 0) {
|
|
619
|
+
const discarded = strayLines.length;
|
|
620
|
+
strayLines.length = 0;
|
|
621
|
+
process.stdout.write(` ${renderDrainNotice(discarded)}\n`);
|
|
622
|
+
}
|
|
623
|
+
process.stdout.write(query);
|
|
624
|
+
const timer = setTimeout(() => settleWaiter(undefined), timeoutMs);
|
|
625
|
+
timer.unref?.();
|
|
626
|
+
try {
|
|
627
|
+
const answer = await new Promise((resolvePromise) => {
|
|
628
|
+
waiter = resolvePromise;
|
|
629
|
+
});
|
|
630
|
+
abortedRead = answer === undefined;
|
|
631
|
+
return answer;
|
|
632
|
+
}
|
|
633
|
+
finally {
|
|
634
|
+
clearTimeout(timer);
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
write: (line) => {
|
|
638
|
+
process.stdout.write(`${line}\n`);
|
|
639
|
+
},
|
|
640
|
+
writeRaw: (text) => {
|
|
641
|
+
process.stdout.write(text);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const session = await openPreReceiptSignupAsk({
|
|
645
|
+
io,
|
|
646
|
+
stateFilePath: signupStateFilePath(),
|
|
647
|
+
width: signupTerminalWidth(),
|
|
648
|
+
preambleLines: [signupCopy.waitLine]
|
|
649
|
+
});
|
|
650
|
+
if (!session) {
|
|
651
|
+
lineInterface.close();
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
session,
|
|
656
|
+
needsFreshLine: () => abortedRead,
|
|
657
|
+
runConsent: async (outcome) => {
|
|
658
|
+
if (interrupted)
|
|
659
|
+
return;
|
|
660
|
+
try {
|
|
661
|
+
await runSignupConsentAfterReceipt({
|
|
662
|
+
io,
|
|
663
|
+
stateFilePath: signupStateFilePath(),
|
|
664
|
+
payload: outcome.payload,
|
|
665
|
+
stamped: outcome.stamped
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// Consent must never break the receipt path.
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
close: () => {
|
|
673
|
+
lineInterface.close();
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
//# sourceMappingURL=signup.js.map
|
|
@@ -558,8 +558,10 @@ function freshnessSegment(snapshot, now) {
|
|
|
558
558
|
const ageMs = now.getTime() - Date.parse(snapshot.lastSuccessAt ?? snapshot.generatedAt);
|
|
559
559
|
if (ageMs < 0)
|
|
560
560
|
return "clock mismatch";
|
|
561
|
+
// "cache Nm old" states the fact; "stale" overclaimed at minutes-old ages
|
|
562
|
+
// next to the product's multi-day evidence windows (shipped-audit fix).
|
|
561
563
|
return ageMs > STALE_AFTER_MS
|
|
562
|
-
? `
|
|
564
|
+
? `cache ${formatAge(ageMs)} old`
|
|
563
565
|
: `updated ${formatAge(ageMs)}`;
|
|
564
566
|
}
|
|
565
567
|
function formatAge(rawAgeMs) {
|