New:Socket for Asana Is Now Available.Learn more
Get Started

martin-loop

Package Overview
Dependencies
Maintainers
1
Versions
51
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

martin-loop - npm Package Compare versions

Comparing version
0.5.5
to
0.5.6
+13
benchmarks/README.md
# Benchmarks
This workspace contains deterministic benchmark suites used to evaluate governed agent execution and reproducibility.
Use `npx martin-loop bench --suite under-3-challenge` for the primary public benchmark lane.
## What the benchmarks measure
MartinLoop benchmarks are evidence for parts of the execution-control lifecycle, not a claim that every coding task will produce the same cost or quality result.
Use them to inspect governed behavior around budgets, attempts, verification, failure classification, and outcome evidence. The broader product lifecycle is `DEFINE -> PREFLIGHT -> CONTROL -> VERIFY -> RECOVER -> PROVE -> ANALYZE`.
For agent-readable product context see [`../llms.txt`](../llms.txt) and [`../docs/for-agents.md`](../docs/for-agents.md).
# Demo Workspace
The `seeded-workspace` folder provides a disposable project for first-run guided demos and receipt walkthroughs.
Use the demo to understand the public flow from Definition of Done through Controlled Run and Verified Handoff without pointing MartinLoop at a production repository.
For the current product lifecycle and trust boundaries see [`../README.md`](../README.md), [`../llms.txt`](../llms.txt), and [`../docs/for-agents.md`](../docs/for-agents.md).
/**
* sync-client.ts — durable local queue + upload for syncing LoopRecords to a hosted Control Plane.
*
* Opt-in: silent no-op unless MARTIN_TELEMETRY_ENDPOINT and MARTIN_API_TOKEN are set.
*
* Contracts:
* syncLoopToHosted — never throws; all errors are caught and logged to stderr.
* flushSyncQueue — may throw on unrecoverable filesystem errors (permission denied, etc.).
* syncQueueStatus — may throw on unrecoverable filesystem errors.
*
* Verified server contract (POST /api/runs/sync):
* Auth: Authorization: Bearer martin_cp_<token> (CP-issued credential, "ingest" scope)
* Dedup: Server deduplicates by (tenantId, loopId). Duplicate events within a run are
* deduplicated by eventId. Duplicate sync → 202 { ok: true, replayedEvents: N,
* acceptedEvents: 0 } — treated as success.
* 401: Bad/missing/revoked token — permanent, do not retry.
* 403: Missing "ingest" scope — permanent, do not retry.
* 400: Invalid payload (missing loopId, empty events, bad schema) — permanent.
* 409: Backdated syncedAt (earlier than existing lastSyncedAt) — permanent, do not retry.
* 429: Rate limit — transient; respect Retry-After if present.
* 5xx: Server error — transient, retry.
*
* Failure modes:
* Transient (timeout, offline, 429, 5xx) → item stays in queue for flushSyncQueue().
* Permanent (4xx exc. 429) → item moved to quarantine dir with reason.
* Queue full (200 items) → oldest by enqueuedAt quarantined; if quarantine
* fails, new item is NOT enqueued (error logged).
* Corrupt queue file → quarantined; skipped if quarantine fails.
* Oversized payload (> 256 KB) → rejected before enqueue; logged to stderr.
*
* Concurrent process safety:
* Items are claimed via atomic rename to .inflight/ before upload.
* Only the process that wins the rename proceeds to upload.
* Stale .inflight items (from crashed processes) are recovered at flush start.
*
* Attempt persistence:
* attempts and nextRetryNotBefore are persisted to the queue file after each attempt.
* FLUSH_MAX_ATTEMPTS is a lifetime cap enforced across separate invocations.
*/
import type { LoopRecord } from "../contracts/index.js";
/** Portable basename — handles both forward and backslash separators on all platforms. @internal */
export declare function queueFileName(filePath: string): string;
interface HostedRunEventDraft {
eventId: string;
eventType: string;
occurredAt: string;
sequence: number;
attemptId?: string;
payload?: Record<string, unknown>;
}
interface HostedRunSyncDraft {
loopId: string;
workspaceId?: string;
projectId?: string;
task: {
title: string;
objective: string;
};
status?: string;
budget?: {
spentUsd?: number;
};
events: HostedRunEventDraft[];
syncedAt?: string;
}
interface SyncQueueItem {
queueId: string;
loopId: string;
payload: HostedRunSyncDraft;
enqueuedAt: string;
attempts: number;
lastAttemptAt?: string;
nextRetryNotBefore?: string;
payloadBytes: number;
}
type UploadResult = {
ok: true;
} | {
ok: false;
permanent: boolean;
retryAfterMs?: number;
};
/**
* @internal Exported for targeted tests only. Server deduplicates by (tenantId, loopId).
*/
export declare function computeIdempotencyKey(loopId: string, body: HostedRunSyncDraft): string;
/**
* @internal Exported for targeted HTTP behavior tests only.
*/
export declare function attemptUpload(item: SyncQueueItem, endpoint: string, token: string): Promise<UploadResult>;
/**
* Atomically writes a LoopRecord to the local sync queue. This is the durability
* guarantee — the record is persisted before this function returns.
*
* Must be awaited by the caller. Never throws — errors are caught and logged to
* stderr so the governed run output is never blocked.
*
* Opt-in: silent no-op when MARTIN_TELEMETRY_ENDPOINT or MARTIN_API_TOKEN are unset.
* Use `martin sync flush` or the background flush in index.ts to upload.
*/
export declare function enqueueLoopForHostedSync(loop: LoopRecord, opts: {
runtimeVersion: string;
}): Promise<void>;
/**
* Enqueues a LoopRecord and immediately attempts an upload to the hosted Control Plane.
*
* Never throws — all errors are caught and logged to stderr.
* On transient failure the item stays queued for `martin sync flush`.
* On permanent failure (4xx exc. 429) the item is quarantined with a diagnostic.
*
* Used in tests that exercise the full enqueue + upload path in one call.
* In production, index.ts uses enqueueLoopForHostedSync + flushSyncQueue separately.
*/
export declare function syncLoopToHosted(loop: LoopRecord, opts: {
runtimeVersion: string;
}): Promise<void>;
/**
* Processes the sync queue: recovers stale inflight items, then for each eligible item
* (not within backoff window, under attempt cap) attempts one upload.
*
* Attempt count and backoff are persisted — multiple flush invocations count toward
* the FLUSH_MAX_ATTEMPTS lifetime cap per item, not per invocation.
*
* May throw on unrecoverable filesystem errors (permission denied, disk full, etc.).
* Called by `martin sync flush`.
*/
export declare function flushSyncQueue(): Promise<void>;
/**
* Prints the current sync queue and quarantine state.
* May throw on unrecoverable filesystem errors.
* Called by `martin sync status`.
*/
export declare function syncQueueStatus(): Promise<void>;
export {};
/**
* sync-client.ts — durable local queue + upload for syncing LoopRecords to a hosted Control Plane.
*
* Opt-in: silent no-op unless MARTIN_TELEMETRY_ENDPOINT and MARTIN_API_TOKEN are set.
*
* Contracts:
* syncLoopToHosted — never throws; all errors are caught and logged to stderr.
* flushSyncQueue — may throw on unrecoverable filesystem errors (permission denied, etc.).
* syncQueueStatus — may throw on unrecoverable filesystem errors.
*
* Verified server contract (POST /api/runs/sync):
* Auth: Authorization: Bearer martin_cp_<token> (CP-issued credential, "ingest" scope)
* Dedup: Server deduplicates by (tenantId, loopId). Duplicate events within a run are
* deduplicated by eventId. Duplicate sync → 202 { ok: true, replayedEvents: N,
* acceptedEvents: 0 } — treated as success.
* 401: Bad/missing/revoked token — permanent, do not retry.
* 403: Missing "ingest" scope — permanent, do not retry.
* 400: Invalid payload (missing loopId, empty events, bad schema) — permanent.
* 409: Backdated syncedAt (earlier than existing lastSyncedAt) — permanent, do not retry.
* 429: Rate limit — transient; respect Retry-After if present.
* 5xx: Server error — transient, retry.
*
* Failure modes:
* Transient (timeout, offline, 429, 5xx) → item stays in queue for flushSyncQueue().
* Permanent (4xx exc. 429) → item moved to quarantine dir with reason.
* Queue full (200 items) → oldest by enqueuedAt quarantined; if quarantine
* fails, new item is NOT enqueued (error logged).
* Corrupt queue file → quarantined; skipped if quarantine fails.
* Oversized payload (> 256 KB) → rejected before enqueue; logged to stderr.
*
* Concurrent process safety:
* Items are claimed via atomic rename to .inflight/ before upload.
* Only the process that wins the rename proceeds to upload.
* Stale .inflight items (from crashed processes) are recovered at flush start.
*
* Attempt persistence:
* attempts and nextRetryNotBefore are persisted to the queue file after each attempt.
* FLUSH_MAX_ATTEMPTS is a lifetime cap enforced across separate invocations.
*/
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
/** Portable basename — handles both forward and backslash separators on all platforms. @internal */
export function queueFileName(filePath) {
return filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? `unknown-${Date.now()}.json`;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const QUEUE_MAX_SIZE = 200;
const UPLOAD_TIMEOUT_MS = 10_000;
const FLUSH_MAX_ATTEMPTS = 5;
const BACKOFF_BASE_MS = 1_000;
const BACKOFF_CAP_MS = 30_000;
const RETRY_AFTER_MIN_MS = 1_000;
/**
* Conservative stale-inflight threshold — well above the upload timeout to avoid
* reclaiming items from processes still actively uploading.
*/
const CLAIM_STALE_MS = 5 * 60 * 1_000; // 5 minutes
const MAX_PAYLOAD_BYTES = 256 * 1_024; // 256 KB
const QUARANTINE_MAX_ITEMS = 500;
const QUARANTINE_MAX_BYTES = 20 * 1_024 * 1_024; // 20 MB
// ---------------------------------------------------------------------------
// Queue directory helpers
// ---------------------------------------------------------------------------
/**
* Returns the active queue directory.
* MARTIN_SYNC_QUEUE_DIR is an internal test override — not a supported public env var.
*/
function resolveQueueDir() {
return process.env["MARTIN_SYNC_QUEUE_DIR"] ?? join(homedir(), ".martin", "runs", ".sync-queue");
}
function resolveQuarantineDir(queueDir) {
return join(queueDir, ".quarantine");
}
function resolveInflightDir(queueDir) {
return join(queueDir, ".inflight");
}
// ---------------------------------------------------------------------------
// Payload builder
// ---------------------------------------------------------------------------
function buildIngestBody(loop, _runtimeVersion) {
const events = [];
// Always-present lifecycle snapshot — guarantees events[] is never empty.
events.push({
eventId: `evt_run_synced_${loop.loopId}`,
eventType: "run.synced",
occurredAt: loop.updatedAt ?? loop.createdAt,
sequence: 0,
payload: { lifecycleState: loop.lifecycleState, status: loop.status },
});
// One event per attempt — taxonomy locked 2026-08-21.
for (const attempt of loop.attempts) {
const eventType = attempt.failureClass != null ? "attempt.failed" : "attempt.completed";
events.push({
eventId: `evt_attempt_${attempt.attemptId}`,
eventType,
occurredAt: attempt.completedAt ?? attempt.startedAt,
sequence: attempt.index + 1,
attemptId: attempt.attemptId,
payload: {
...(attempt.failureClass != null && { failureClass: attempt.failureClass }),
...(attempt.summary != null && { summary: attempt.summary }),
...(attempt.model != null && { model: attempt.model }),
},
});
}
return {
loopId: loop.loopId,
workspaceId: loop.workspaceId,
projectId: loop.projectId,
task: {
title: loop.task.title ?? loop.task.objective ?? "",
objective: loop.task.objective ?? loop.task.title ?? "",
},
status: loop.status,
budget: {
spentUsd: loop.cost?.actualUsd ?? loop.cost?.estimatedUsd,
},
events,
syncedAt: new Date().toISOString(),
};
}
/**
* @internal Exported for targeted tests only. Server deduplicates by (tenantId, loopId).
*/
export function computeIdempotencyKey(loopId, body) {
return createHash("sha256")
.update(loopId + JSON.stringify(body))
.digest("hex");
}
// ---------------------------------------------------------------------------
// Atomic write — all queue file writes go through this helper
// ---------------------------------------------------------------------------
async function atomicWriteJson(filePath, data) {
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, JSON.stringify(data), "utf8");
await rename(tmp, filePath);
}
// ---------------------------------------------------------------------------
// Inflight filename protocol
// ---------------------------------------------------------------------------
/**
* Structured inflight filename: <claimedAtEpochMs>.<claimUuid>.<queueId>.json
*
* The claim timestamp and owner UUID are encoded in the rename destination, so they
* are established by the same atomic OS rename that acquires ownership. Nothing is
* written after the rename — the filename itself is the authoritative claim record.
*
* Legacy inflight filenames (<queueId>.json) are handled separately and conservatively.
*/
const INFLIGHT_RE = /^(\d+)\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$/i;
function parseInflightName(name) {
const m = INFLIGHT_RE.exec(name);
if (!m)
return null;
return {
claimedAtMs: Number(m[1]),
claimId: m[2],
queueId: m[3],
};
}
// ---------------------------------------------------------------------------
// Queue listing
// ---------------------------------------------------------------------------
/**
* Lists .json filenames in dir.
* Treats ENOENT as empty. All other errors (permissions, I/O) propagate — they are not "empty".
*/
async function safeListQueue(dir) {
try {
const entries = await readdir(dir);
return entries.filter((f) => f.endsWith(".json"));
}
catch (err) {
if (err.code === "ENOENT")
return [];
throw err;
}
}
/**
* Returns all parseable queue items sorted by enqueuedAt ascending (oldest first).
* Unparseable files are silently excluded — callers identify them via safeListQueue diff.
*/
async function listQueueOldestFirst(queueDir) {
const files = await safeListQueue(queueDir);
const results = await Promise.all(files.map(async (f) => {
try {
const raw = await readFile(join(queueDir, f), "utf8");
const item = JSON.parse(raw);
if (typeof item.loopId !== "string" || typeof item.enqueuedAt !== "string")
return null;
return { file: f, item };
}
catch (err) {
if (err instanceof SyntaxError)
return null; // corrupt JSON — quarantined later by flush
if (err.code === "ENOENT")
return null; // vanished between readdir/readFile
throw err; // permission/IO error — propagate
}
}));
return results
.filter((x) => x !== null)
.sort((a, b) => a.item.enqueuedAt.localeCompare(b.item.enqueuedAt));
}
// ---------------------------------------------------------------------------
// Retry-After parsing — safe against malformed, negative, and extreme values
// ---------------------------------------------------------------------------
function parseRetryAfterMs(headerValue) {
if (!headerValue)
return undefined;
const asSeconds = Number(headerValue);
const ms = Number.isFinite(asSeconds)
? asSeconds * 1_000
: Date.parse(headerValue) - Date.now();
if (!Number.isFinite(ms) || ms <= 0)
return undefined; // malformed/negative → fall back to backoff
return Math.min(Math.max(ms, RETRY_AFTER_MIN_MS), BACKOFF_CAP_MS);
}
// ---------------------------------------------------------------------------
// Atomic claiming — prevents concurrent upload of same item
// ---------------------------------------------------------------------------
/**
* In-process serialization for concurrent claim attempts.
*
* Cross-process ownership is guaranteed by the atomic OS rename in claimItem.
* This Set provides additional in-process serialization: it is checked and updated
* synchronously (no await before the check-and-add), so it is atomic within the JS
* event loop and prevents two coroutines in the same process from both submitting
* renames for the same source path to the OS I/O queue simultaneously.
*
* Keyed by the canonical full source path, not only queueId, to ensure correct
* scoping when multiple queue directories are active (e.g., in tests).
*/
const activeClaimPaths = new Set();
/**
* Atomically claims a queue item by renaming it to a structured inflight filename:
* queue/<queueId>.json → .inflight/<claimedAtEpochMs>.<claimUuid>.<queueId>.json
*
* The claim timestamp and identity are encoded in the rename destination, so they are
* established by the same atomic OS operation that acquires ownership. Nothing is
* written after the rename — the filename is the claim record.
*
* Returns the inflight path on success; undefined if another worker won the race
* (ENOENT from OS rename, or in-process serialization lock already held).
*/
async function claimItem(file, queueDir) {
// Validate file is exactly <uuid>.json — guards against path traversal.
if (!file.endsWith(".json"))
return undefined;
const queueId = file.slice(0, -5);
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(queueId)) {
return undefined;
}
const src = join(queueDir, file);
// In-process serialization: checked and set synchronously before the first await,
// so this is atomic within the JS event loop. Keyed by full source path.
if (activeClaimPaths.has(src))
return undefined;
activeClaimPaths.add(src);
try {
const inflightDir = resolveInflightDir(queueDir);
await mkdir(inflightDir, { recursive: true });
// Claim timestamp and UUID encoded in destination filename — atomically established.
const claimedAtMs = Date.now();
const claimUuid = randomUUID();
const dest = join(inflightDir, `${claimedAtMs}.${claimUuid}.${queueId}.json`);
try {
await rename(src, dest);
return dest;
}
catch (err) {
if (err.code === "ENOENT")
return undefined; // lost the race
throw err;
}
}
finally {
activeClaimPaths.delete(src);
}
}
/**
* Releases a claimed item: either marks it done (deletes it) or requeues it.
* Only ENOENT is treated as a safe race condition — other errors propagate.
*/
async function releaseItem(inflightPath, queueDir, outcome) {
if (outcome === "done") {
await rm(inflightPath, { force: true }); // force handles ENOENT safely
return;
}
// Extract the original <queueId>.json name from the inflight filename.
// New format: <epochMs>.<claimUuid>.<queueId>.json → requeue as <queueId>.json
// Legacy format: <queueId>.json → requeue as-is
const base = queueFileName(inflightPath);
const meta = parseInflightName(base);
const queueFile = meta ? `${meta.queueId}.json` : base;
const dest = join(queueDir, queueFile);
try {
await rename(inflightPath, dest);
}
catch (err) {
if (err.code === "ENOENT")
return; // already swept — safe race
process.stderr.write(`[martin sync] Failed to requeue ${queueFile}: ${err instanceof Error ? err.message : String(err)}\n`);
throw err;
}
}
/**
* Recovers .inflight items abandoned by crashed or killed processes.
*
* New-format claims (<claimedAtMs>.<claimUuid>.<queueId>.json): staleness is determined
* from the epoch timestamp encoded in the filename — the same atomic rename that acquired
* ownership established this timestamp, so it can never be confused with the original
* queue-write mtime.
*
* Legacy-format claims (<queueId>.json): fall back to file mtime conservatively.
* This path exists only for inflight files created before this protocol was introduced.
*
* Called at the start of every flushSyncQueue() before any new claims are made.
*/
async function recoverStaleInflight(queueDir) {
const inflightDir = resolveInflightDir(queueDir);
let entries;
try {
entries = await readdir(inflightDir);
}
catch (err) {
if (err.code === "ENOENT")
return;
throw err;
}
for (const entry of entries) {
if (!entry.endsWith(".json"))
continue;
const p = join(inflightDir, entry);
try {
const meta = parseInflightName(entry);
if (meta) {
// New format: staleness from the atomically-established claim timestamp in the name.
if (Date.now() - meta.claimedAtMs > CLAIM_STALE_MS) {
await releaseItem(p, queueDir, "requeue");
process.stderr.write(`[martin sync] Recovered stale inflight item: ${entry}\n`);
}
}
else {
// Legacy format: fall back to mtime conservatively.
const s = await stat(p);
if (Date.now() - s.mtimeMs > CLAIM_STALE_MS) {
await releaseItem(p, queueDir, "requeue");
process.stderr.write(`[martin sync] Recovered stale legacy inflight item: ${entry}\n`);
}
}
}
catch (err) {
if (err.code === "ENOENT")
continue;
throw err;
}
}
}
// ---------------------------------------------------------------------------
// Quarantine — bounded, never deletes source on failure
// ---------------------------------------------------------------------------
async function enforceQuarantineBounds(quarantineDir) {
const files = await safeListQueue(quarantineDir);
if (files.length === 0)
return;
const fileStats = await Promise.all(files.map(async (f) => {
try {
const s = await stat(join(quarantineDir, f));
return { file: f, size: s.size, mtime: s.mtimeMs };
}
catch {
return { file: f, size: 0, mtime: Date.now() };
}
}));
fileStats.sort((a, b) => a.mtime - b.mtime); // oldest first
let totalItems = fileStats.length;
let totalBytes = fileStats.reduce((acc, f) => acc + f.size, 0);
for (const f of fileStats) {
if (totalItems <= QUARANTINE_MAX_ITEMS && totalBytes <= QUARANTINE_MAX_BYTES)
break;
try {
await rm(join(quarantineDir, f.file), { force: true });
await rm(`${join(quarantineDir, f.file)}.reason`, { force: true });
process.stderr.write(`[martin sync] Quarantine cap: purged ${f.file}\n`);
totalItems--;
totalBytes -= f.size;
}
catch (err) {
process.stderr.write(`[martin sync] Quarantine cap: delete failed for ${f.file}: ${err instanceof Error ? err.message : String(err)}\n`);
break; // stop on delete errors — don't loop on a broken filesystem
}
}
}
/**
* Moves filePath to the quarantine directory.
* Returns true if the move succeeded; false if it failed (source file is left intact).
* NEVER deletes the source file if quarantine fails.
*/
async function quarantine(filePath, queueDir, reason) {
const dir = resolveQuarantineDir(queueDir);
try {
await mkdir(dir, { recursive: true });
}
catch (err) {
process.stderr.write(`[martin sync] Cannot create quarantine dir: ${err instanceof Error ? err.message : String(err)}\n`);
return false; // source file intact
}
// Normalize quarantine filename: strip inflight metadata so quarantine entries are
// named <queueId>.json regardless of whether the source was a queue or inflight file.
const rawName = queueFileName(filePath);
const meta = parseInflightName(rawName);
const name = meta ? `${meta.queueId}.json` : rawName;
const dest = join(dir, name);
try {
await rename(filePath, dest);
}
catch (err) {
process.stderr.write(`[martin sync] Cannot quarantine ${name}: ${err instanceof Error ? err.message : String(err)}. Record left in place.\n`);
return false; // source file intact — never deleted on failure
}
// Non-fatal post-move operations: failure here doesn't undo the quarantine
try {
await atomicWriteJson(`${dest}.reason`, { reason, quarantinedAt: new Date().toISOString() });
}
catch (err) {
process.stderr.write(`[martin sync] Quarantine reason write failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`);
}
try {
await enforceQuarantineBounds(dir);
}
catch (err) {
process.stderr.write(`[martin sync] Quarantine bounds enforcement failed: ${err instanceof Error ? err.message : String(err)}\n`);
}
return true;
}
// ---------------------------------------------------------------------------
// Enqueue
// ---------------------------------------------------------------------------
async function enqueue(item, queueDir) {
await mkdir(queueDir, { recursive: true });
// Cap: quarantine the oldest item (by enqueuedAt) to make room
const existing = await listQueueOldestFirst(queueDir);
if (existing.length >= QUEUE_MAX_SIZE) {
const oldest = existing[0];
process.stderr.write(`[martin sync] Queue full (${QUEUE_MAX_SIZE} items). Quarantining oldest: ${oldest.file}\n`);
const moved = await quarantine(join(queueDir, oldest.file), queueDir, "queue_full");
if (!moved) {
// Cannot make room — refuse to exceed the cap
throw new Error(`Queue full (${QUEUE_MAX_SIZE} items) and oldest item could not be quarantined — new record dropped.`);
}
}
const filePath = join(queueDir, `${item.queueId}.json`);
await atomicWriteJson(filePath, item);
}
// ---------------------------------------------------------------------------
// HTTP upload
// ---------------------------------------------------------------------------
/**
* @internal Exported for targeted HTTP behavior tests only.
*/
export async function attemptUpload(item, endpoint, token) {
const url = `${endpoint.replace(/\/$/, "")}/api/runs/sync`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(item.payload),
});
// 202: accepted (new or duplicate events). Duplicate sync → replayedEvents>0, acceptedEvents===0.
if (res.ok)
return { ok: true };
if (res.status === 429) {
const retryAfterMs = parseRetryAfterMs(res.headers.get("Retry-After"));
return { ok: false, permanent: false, retryAfterMs };
}
if (res.status >= 500)
return { ok: false, permanent: false };
// 4xx (exc. 429): permanent — includes 409 (backdated syncedAt), 401, 403, 400
return { ok: false, permanent: true };
}
catch {
return { ok: false, permanent: false }; // network error or AbortError (timeout)
}
finally {
clearTimeout(timer);
}
}
function backoffDelay(attempt, retryAfterMs) {
if (retryAfterMs != null && retryAfterMs > 0)
return Math.min(retryAfterMs, BACKOFF_CAP_MS);
const exp = Math.min(BACKOFF_BASE_MS * Math.pow(2, attempt), BACKOFF_CAP_MS);
const jitter = Math.random() * 0.3 * exp;
return Math.floor(exp + jitter);
}
/**
* Validates opt-in env vars, builds the ingest payload, checks size, creates a
* SyncQueueItem, and atomically writes it to the queue directory.
*
* Returns the enqueued context on success; undefined if opt-in is disabled or
* the payload is rejected (too large). Throws on queue-write failures so the
* caller can decide how to handle them.
*/
async function buildAndEnqueue(loop, opts) {
const endpoint = process.env["MARTIN_TELEMETRY_ENDPOINT"]?.trim();
const token = process.env["MARTIN_API_TOKEN"]?.trim();
if (!endpoint || !token)
return undefined; // opt-in — silent no-op
const queueDir = resolveQueueDir();
const payload = buildIngestBody(loop, opts.runtimeVersion);
const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
if (payloadBytes > MAX_PAYLOAD_BYTES) {
process.stderr.write(`[martin sync] Payload too large (${payloadBytes} bytes, max ${MAX_PAYLOAD_BYTES}) for loop ${loop.loopId} — not queued.\n`);
return undefined;
}
const item = {
queueId: randomUUID(),
loopId: loop.loopId,
payload,
enqueuedAt: new Date().toISOString(),
attempts: 0,
payloadBytes,
};
await enqueue(item, queueDir);
return { item, queueDir, endpoint, token };
}
// ---------------------------------------------------------------------------
// Public: enqueueLoopForHostedSync
// ---------------------------------------------------------------------------
/**
* Atomically writes a LoopRecord to the local sync queue. This is the durability
* guarantee — the record is persisted before this function returns.
*
* Must be awaited by the caller. Never throws — errors are caught and logged to
* stderr so the governed run output is never blocked.
*
* Opt-in: silent no-op when MARTIN_TELEMETRY_ENDPOINT or MARTIN_API_TOKEN are unset.
* Use `martin sync flush` or the background flush in index.ts to upload.
*/
export async function enqueueLoopForHostedSync(loop, opts) {
try {
await buildAndEnqueue(loop, opts);
}
catch (err) {
process.stderr.write(`[martin sync] Sync deferred for loop ${loop.loopId}: ${err instanceof Error ? err.message : String(err)}\n`);
}
}
// ---------------------------------------------------------------------------
// Public: syncLoopToHosted
// ---------------------------------------------------------------------------
/**
* Enqueues a LoopRecord and immediately attempts an upload to the hosted Control Plane.
*
* Never throws — all errors are caught and logged to stderr.
* On transient failure the item stays queued for `martin sync flush`.
* On permanent failure (4xx exc. 429) the item is quarantined with a diagnostic.
*
* Used in tests that exercise the full enqueue + upload path in one call.
* In production, index.ts uses enqueueLoopForHostedSync + flushSyncQueue separately.
*/
export async function syncLoopToHosted(loop, opts) {
try {
const ctx = await buildAndEnqueue(loop, opts);
if (!ctx)
return;
const { item, queueDir, endpoint, token } = ctx;
// Claim before uploading — prevents race with a concurrent flushSyncQueue call
const inflightPath = await claimItem(`${item.queueId}.json`, queueDir);
if (!inflightPath) {
// Another process claimed it (extremely unlikely). Leave it for flush.
return;
}
const result = await attemptUpload(item, endpoint, token);
if (result.ok) {
await releaseItem(inflightPath, queueDir, "done");
return;
}
if (result.permanent) {
process.stderr.write(`[martin sync] Permanent upload failure for loop ${loop.loopId} — quarantining. Check MARTIN_API_TOKEN and MARTIN_TELEMETRY_ENDPOINT.\n`);
const moved = await quarantine(inflightPath, queueDir, "permanent_4xx");
if (!moved)
await releaseItem(inflightPath, queueDir, "requeue");
return;
}
// Transient — persist attempt state before releasing back to queue
const delay = backoffDelay(0, result.retryAfterMs);
const updated = {
...item,
attempts: 1,
lastAttemptAt: new Date().toISOString(),
nextRetryNotBefore: new Date(Date.now() + delay).toISOString(),
};
await atomicWriteJson(inflightPath, updated);
await releaseItem(inflightPath, queueDir, "requeue");
process.stderr.write(`[martin sync] Upload deferred for loop ${loop.loopId} — will retry with \`martin sync flush\`.\n`);
}
catch (err) {
// Catch-all: filesystem failures, queue-full errors, etc.
process.stderr.write(`[martin sync] Sync deferred for loop ${loop.loopId}: ${err instanceof Error ? err.message : String(err)}\n`);
}
}
// ---------------------------------------------------------------------------
// Public: flushSyncQueue
// ---------------------------------------------------------------------------
/**
* Processes the sync queue: recovers stale inflight items, then for each eligible item
* (not within backoff window, under attempt cap) attempts one upload.
*
* Attempt count and backoff are persisted — multiple flush invocations count toward
* the FLUSH_MAX_ATTEMPTS lifetime cap per item, not per invocation.
*
* May throw on unrecoverable filesystem errors (permission denied, disk full, etc.).
* Called by `martin sync flush`.
*/
export async function flushSyncQueue() {
const endpoint = process.env["MARTIN_TELEMETRY_ENDPOINT"]?.trim();
const token = process.env["MARTIN_API_TOKEN"]?.trim();
if (!endpoint || !token) {
process.stderr.write("[martin sync] MARTIN_TELEMETRY_ENDPOINT and MARTIN_API_TOKEN must be set to flush the queue.\n");
return;
}
const queueDir = resolveQueueDir();
// Recover items abandoned by crashed processes before claiming new ones
await recoverStaleInflight(queueDir);
// Quarantine files that are present in the directory but cannot be parsed
const allFiles = await safeListQueue(queueDir);
const parseable = await listQueueOldestFirst(queueDir);
const parseableSet = new Set(parseable.map((x) => x.file));
for (const f of allFiles) {
if (!parseableSet.has(f)) {
process.stderr.write(`[martin sync] Corrupt queue file ${f} — quarantining.\n`);
await quarantine(join(queueDir, f), queueDir, "corrupt");
}
}
if (parseable.length === 0) {
process.stdout.write("[martin sync] Queue is empty.\n");
return;
}
const now = Date.now();
const eligible = parseable.filter((x) => !x.item.nextRetryNotBefore || Date.parse(x.item.nextRetryNotBefore) <= now);
const deferred = parseable.length - eligible.length;
process.stdout.write(`[martin sync] Flushing ${eligible.length} eligible item(s)${deferred > 0 ? ` (${deferred} deferred by backoff)` : ""}…\n`);
let succeeded = 0;
let quarantinedCount = 0;
let stillPending = 0;
for (const { file } of eligible) {
const inflightPath = await claimItem(file, queueDir);
if (!inflightPath)
continue; // another process claimed it
let currentItem;
try {
currentItem = JSON.parse(await readFile(inflightPath, "utf8"));
}
catch {
process.stderr.write(`[martin sync] Cannot read claimed item ${file} — releasing.\n`);
await releaseItem(inflightPath, queueDir, "requeue");
continue;
}
// Enforce lifetime attempt cap across invocations
if (currentItem.attempts >= FLUSH_MAX_ATTEMPTS) {
process.stderr.write(`[martin sync] Loop ${currentItem.loopId} exhausted ${FLUSH_MAX_ATTEMPTS} attempts — quarantining.\n`);
const moved = await quarantine(inflightPath, queueDir, "max_attempts");
if (moved)
quarantinedCount++;
else
await releaseItem(inflightPath, queueDir, "requeue");
continue;
}
const result = await attemptUpload(currentItem, endpoint, token);
if (result.ok) {
await releaseItem(inflightPath, queueDir, "done");
succeeded++;
}
else if (result.permanent) {
process.stderr.write(`[martin sync] Permanent failure for loop ${currentItem.loopId} — quarantining.\n`);
const moved = await quarantine(inflightPath, queueDir, "permanent_4xx");
if (moved)
quarantinedCount++;
else
await releaseItem(inflightPath, queueDir, "requeue");
}
else {
// Persist incremented attempt count and backoff window
const delay = backoffDelay(currentItem.attempts, result.retryAfterMs);
const updated = {
...currentItem,
attempts: currentItem.attempts + 1,
lastAttemptAt: new Date().toISOString(),
nextRetryNotBefore: new Date(Date.now() + delay).toISOString(),
};
await atomicWriteJson(inflightPath, updated);
await releaseItem(inflightPath, queueDir, "requeue");
stillPending++;
}
}
process.stdout.write(`[martin sync] Done — ${succeeded} uploaded, ${quarantinedCount} quarantined, ${stillPending} still pending.\n`);
}
// ---------------------------------------------------------------------------
// Public: syncQueueStatus
// ---------------------------------------------------------------------------
/**
* Prints the current sync queue and quarantine state.
* May throw on unrecoverable filesystem errors.
* Called by `martin sync status`.
*/
export async function syncQueueStatus() {
const queueDir = resolveQueueDir();
const items = await listQueueOldestFirst(queueDir);
const allFiles = await safeListQueue(queueDir);
if (allFiles.length === 0) {
process.stdout.write("[martin sync] Queue is empty.\n");
}
else {
process.stdout.write(`[martin sync] ${allFiles.length} item(s) pending upload:\n`);
for (const { item } of items) {
const backoffNote = item.nextRetryNotBefore
? `, retry after: ${item.nextRetryNotBefore}`
: "";
process.stdout.write(` • ${item.loopId} (queued ${item.enqueuedAt}, attempts: ${item.attempts}${backoffNote})\n`);
}
const corrupt = allFiles.length - items.length;
if (corrupt > 0)
process.stdout.write(` • ${corrupt} unreadable/corrupt item(s)\n`);
}
const quarantineDir = resolveQuarantineDir(queueDir);
try {
const quarantined = (await readdir(quarantineDir)).filter((f) => f.endsWith(".json"));
if (quarantined.length > 0) {
process.stdout.write(`[martin sync] ${quarantined.length} item(s) in quarantine — inspect ~/.martin/runs/.sync-queue/.quarantine/\n`);
}
}
catch (err) {
if (err.code !== "ENOENT")
throw err;
// ENOENT: no quarantine dir yet — fine
}
}
//# sourceMappingURL=sync-client.js.map
+2
-1

@@ -752,2 +752,3 @@ /**

});
const verificationConfigured = verification.binding.commands.length > 0;
// Check for zero-diff (agent ran but made no file changes)

@@ -795,3 +796,3 @@ const postRunChangedFiles = gitRepoRoot

const structuralHint = inferStructuralClassHint(agentText, verification.summary, agentResult.exitCode, request.context.objective);
if (verification.passed) {
if (verification.passed || !verificationConfigured) {
return {

@@ -798,0 +799,0 @@ status: "completed",

@@ -157,3 +157,9 @@ import { spawn } from "node:child_process";

if (steps.length === 0) {
return { passed: true, summary: "No verification commands specified.", steps: [], binding: executionBinding };
return {
passed: false,
summary: "No verification commands specified; execution is not VERIFIED.",
steps: [],
warnings: ["Execution completed without verifier evidence."],
binding: executionBinding,
};
}

@@ -160,0 +166,0 @@ const failedSteps = [];

@@ -350,7 +350,10 @@ /**

};
const verificationConfigured = verification.binding.commands.length > 0;
return {
status: verification.passed ? "completed" : "failed",
status: verification.passed || !verificationConfigured ? "completed" : "failed",
summary: verification.passed
? `${model} completed the task. Verifier passed.`
: `${model} completed but verifier failed: ${verification.summary}`,
: !verificationConfigured
? `${model} completed the task without verifier evidence; outcome is not VERIFIED.`
: `${model} completed but verifier failed: ${verification.summary}`,
usage: normalizeOpenAiCompatibleUsage({

@@ -366,3 +369,3 @@ model,

execution,
...(verification.passed ? {} : {
...(verification.passed || !verificationConfigured ? {} : {
failure: { message: verification.summary }

@@ -369,0 +372,0 @@ })

@@ -200,2 +200,6 @@ import { probeCodexLaunch, resolveCliCommandAvailability } from "../adapters/index.js";

};
type SyncCommand = {
command: "sync";
sub: "flush" | "status";
};
export type ParsedCliArguments = {

@@ -215,9 +219,5 @@ command: "help";

force: boolean;
} | InspectCommand | ResumeCommand | DoctorCommand | StartCommand | EnableCommand | EnvCommand | ReviewCommand | ReceiptsExplainCommand | NativePhaseCommand | PreflightCommand | TriageCommand | DossierCommand | RunsCommand | McpCommand | EstimateCommand | GateCommand | ModeCommand | CleanCommand | ChallengeCommand | ShareCommand | BadgeCommand | CancelCommand | SignalCommand | {
} | InspectCommand | ResumeCommand | DoctorCommand | StartCommand | EnableCommand | EnvCommand | ReviewCommand | ReceiptsExplainCommand | NativePhaseCommand | PreflightCommand | TriageCommand | DossierCommand | RunsCommand | McpCommand | EstimateCommand | GateCommand | ModeCommand | CleanCommand | ChallengeCommand | ShareCommand | BadgeCommand | CancelCommand | SignalCommand | SyncCommand | {
command: "telemetry";
action: "status" | "explain" | "on" | "off";
} | {
command: "install";
version?: string;
directory?: string;
};

@@ -224,0 +224,0 @@ export declare function executeCli(args: string[]): Promise<{

{
"name": "@martin/cli",
"version": "0.5.5",
"version": "0.5.6",
"type": "module",

@@ -5,0 +5,0 @@ "description": "Open-source execution control for coding agents with verifier-gated completion, stop limits, rollback evidence, and Verified Handoffs.",

@@ -100,2 +100,3 @@ import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchScore, PolicyPhase } from "../contracts/index.js";

canSwitchAdapter?: boolean;
verificationRequired?: boolean;
}): ExitDecision;

@@ -102,0 +103,0 @@ export interface BudgetPreflightInput {

@@ -213,2 +213,10 @@ /**

export function inferExit(input) {
if (input.lastResult.status === "completed" && input.verificationRequired === false) {
return {
shouldExit: true,
lifecycleState: "completed",
status: "completed",
reason: "Execution-only work completed without verifier evidence; outcome is not VERIFIED."
};
}
if (input.lastResult.status === "completed" && input.lastResult.verification.passed) {

@@ -215,0 +223,0 @@ return {

import type { PatchDecision, RollbackBoundaryArtifact, RollbackOutcomeArtifact } from "../contracts/index.js";
export declare class RollbackStateUnavailableError extends Error {
constructor(message: string);
}
export declare function listAttemptChangedFilesSinceBoundary(input: {

@@ -3,0 +6,0 @@ repoRoot?: string;

import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
export class RollbackStateUnavailableError extends Error {
constructor(message) {
super(message);
this.name = "RollbackStateUnavailableError";
}
}
export function listAttemptChangedFilesSinceBoundary(input) {

@@ -15,20 +20,7 @@ if (!input.repoRoot) {

const baselineUntracked = new Set(input.boundary.untrackedFiles);
const baselineContentChanges = input.boundary.snapshots
.filter((snapshot) => snapshot.existed && repoFileDiffersFromSnapshot(input.repoRoot, snapshot))
.map((snapshot) => snapshot.path);
return uniqueSorted([
...repoState.trackedDirtyFiles.filter((filePath) => !baselineTracked.has(filePath)),
...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath)),
...baselineContentChanges
...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath))
]);
}
function repoFileDiffersFromSnapshot(repoRoot, snapshot) {
try {
const current = readFileSync(resolveRepoPath(repoRoot, snapshot.path));
return current.toString("base64") !== snapshot.contentBase64;
}
catch {
return true;
}
}
export async function captureRollbackBoundary(input) {

@@ -151,2 +143,3 @@ if (!input.repoRoot) {

function readGitLines(repoRoot, args) {
let lastFailure = "unknown Git failure";
for (let attempt = 0; attempt < 2; attempt++) {

@@ -163,9 +156,10 @@ const result = spawnSync("git", args, {

}
lastFailure = gitFailureMessage(result);
// Retry once after a short delay — handles git lock-file contention
// that occurs when another process holds .git/index.lock.
if (attempt === 0 && result.status !== 0) {
spawnSync("sleep", ["0.5"], { encoding: "utf8" });
delaySynchronously(500);
}
}
return [];
throw new RollbackStateUnavailableError(`Git rollback state unavailable for ${args.join(" ")}: ${lastFailure}`);
}

@@ -178,3 +172,3 @@ function readGitScalar(repoRoot, args) {

if (result.status !== 0 || typeof result.stdout !== "string") {
return undefined;
throw new RollbackStateUnavailableError(`Git rollback state unavailable for ${args.join(" ")}: ${gitFailureMessage(result)}`);
}

@@ -195,3 +189,6 @@ const value = result.stdout.trim();

}
catch {
catch (error) {
if (!isFileNotFoundError(error)) {
throw new RollbackStateUnavailableError(`Rollback snapshot unavailable for ${normalizeRepoPath(filePath)}: ${toErrorMessage(error)}`);
}
return {

@@ -222,3 +219,3 @@ path: normalizeRepoPath(filePath),

// Retry once after a short delay (git lock-file contention)
spawnSync("sleep", ["0.5"], { encoding: "utf8" });
delaySynchronously(500);
const retry = spawnSync("git", ["restore", "--staged", "--worktree", "--source=HEAD", "--", filePath], { cwd: repoRoot, encoding: "utf8" });

@@ -273,2 +270,18 @@ if (retry.status === 0) {

}
function gitFailureMessage(result) {
if (result.error) {
return result.error.message;
}
if (typeof result.stderr === "string" && result.stderr.trim().length > 0) {
return result.stderr.trim();
}
return `git exited with status ${String(result.status)}`;
}
function isFileNotFoundError(error) {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
function delaySynchronously(milliseconds) {
const signal = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
Atomics.wait(signal, 0, 0, milliseconds);
}
//# sourceMappingURL=rollback.js.map

@@ -36,2 +36,5 @@ // SPDX-FileCopyrightText: MartinLoop contributors

}
if (expected.commands.length === 0) {
return false;
}
if (evidence.binding.runId !== expected.runId ||

@@ -97,3 +100,2 @@ evidence.binding.workspaceId !== expected.workspaceId ||

: undefined;
const groundingEvidenceAvailable = loop.metadata["groundingEvidenceStatus"] !== "unavailable";
const executionMode = hasSimulatedAdapter

@@ -106,3 +108,3 @@ ? "simulated"

executionMode,
governanceClaimEligible: executionMode === "governed" && adapterIds.length > 0 && groundingEvidenceAvailable,
governanceClaimEligible: executionMode === "governed" && adapterIds.length > 0,
};

@@ -163,6 +165,23 @@ }

const executionBoundary = deriveVerifiedHandoffExecutionBoundary(input.loop);
const verifierStepsActuallyPassed = input.verification.steps.length > 0 &&
input.verification.steps.every((step) => step.launched === true &&
step.completed === true &&
step.crashed === false &&
step.timedOut !== true &&
step.exitCode === 0);
const effectiveVerificationStatus = input.verification.status === "passed" && !verifierStepsActuallyPassed
? input.verification.steps.length === 0
? "not_run"
: "contradicted"
: input.verification.status;
const verificationWarnings = [
...input.verification.warnings,
...(effectiveVerificationStatus !== input.verification.status
? ["A VERIFIED claim requires at least one launched, completed, exit-zero verifier step."]
: []),
];
const outcome = resolveVerifiedHandoffOutcome({
lifecycleState: input.loop.lifecycleState,
executionStatus: input.executionStatus,
verificationStatus: input.verification.status,
verificationStatus: effectiveVerificationStatus,
receiptIntegrity: input.receiptIntegrity.state,

@@ -174,3 +193,3 @@ scopeStatus: scope.status,

definitionOfDonePreSatisfied: input.definitionOfDonePreSatisfied,
evidenceContradicted: input.evidenceContradicted ?? input.verification.status === "contradicted",
evidenceContradicted: input.evidenceContradicted ?? effectiveVerificationStatus === "contradicted",
governanceClaimEligible: executionBoundary.governanceClaimEligible,

@@ -204,6 +223,6 @@ unresolvedWorkCount: unresolvedWork.length,

verification: {
status: toEvidenceStatus(input.verification.status),
status: toEvidenceStatus(effectiveVerificationStatus),
summary: input.verification.summary,
checks: input.verification.steps.map(toCheck),
warnings: input.verification.warnings,
warnings: verificationWarnings,
},

@@ -210,0 +229,0 @@ requirements: input.requirements ?? [],

@@ -25,15 +25,2 @@ import { renderTable } from "./table.js";

}
function formatCost(usd, provenance) {
if (provenance === "unavailable") {
return "unavailable";
}
const amount = "$" + usd.toFixed(2);
if (provenance === "actual") {
return amount + " provider-settled actual";
}
if (provenance === "calculated") {
return amount + " calculated from observed usage";
}
return amount + " estimated";
}
function checkSymbol(status) {

@@ -48,2 +35,12 @@ if (status === "PASSED") {

}
function formatCost(usd, provenance) {
if (provenance === "unavailable")
return "unavailable";
const amount = "$" + usd.toFixed(2);
if (provenance === "actual")
return amount + " provider-settled actual";
if (provenance === "calculated")
return amount + " calculated from observed usage";
return amount + " estimated";
}
/**

@@ -50,0 +47,0 @@ * Single source of truth for the trust-authority outcome calculation.

{
"name": "martin-loop",
"private": false,
"version": "0.5.5",
"version": "0.5.6",
"type": "module",

@@ -6,0 +6,0 @@ "description": "Open-source command center for governed AI coding agents with built-in onboarding, hard gates, MCP, and shareable run receipts.",

import type { MartinOutputMode } from "../../contracts/index.js";
export declare class InstallError extends Error {
constructor(message: string);
}
export interface NativeInstallRuntime {
platform: NodeJS.Platform;
arch: string;
fetchBytes(url: string): Promise<Uint8Array>;
verifyExecutable(path: string): string;
now(): number;
}
export interface InstallOptions {
version?: string;
dir?: string;
outputMode: MartinOutputMode;
runtime?: NativeInstallRuntime;
}
export interface InstallResult {
version: string;
installPath: string;
aliasPath: string;
backupPath?: string;
target: string;
assetName: string;
}
export declare function nativeTarget(platformName?: NodeJS.Platform, architecture?: string): string;
export declare function nativeAssetName(target: string): string;
export declare function parseSha256File(contents: string, expectedAsset: string): string;
export declare function runInstall(options: InstallOptions): Promise<InstallResult>;
export declare function verifyInstalledNativeBinary(path: string): {
path: string;
size: number;
sha256: string;
};
// SPDX-FileCopyrightText: MartinLoop contributors
//
// SPDX-License-Identifier: Apache-2.0
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { arch, homedir, platform } from "node:os";
import { join } from "node:path";
const RELEASE_REPOSITORY = "Keesan12/martin-loop";
const PRODUCT_NAME = "martin-loop";
export class InstallError extends Error {
constructor(message) {
super(message);
this.name = "InstallError";
}
}
export function nativeTarget(platformName = platform(), architecture = arch()) {
const os = platformName === "darwin"
? "macos"
: platformName === "win32"
? "win"
: platformName === "linux"
? "linux"
: undefined;
const normalizedArch = architecture === "x64" || architecture === "arm64" ? architecture : undefined;
if (!os) {
throw new InstallError(`Unsupported operating system: ${platformName}`);
}
if (!normalizedArch) {
throw new InstallError(`Unsupported architecture: ${architecture}`);
}
if (os === "win" && normalizedArch !== "x64") {
throw new InstallError("Windows native releases currently support x64 only");
}
return `${os}-${normalizedArch}`;
}
export function nativeAssetName(target) {
return `${PRODUCT_NAME}-${target}${target.startsWith("win-") ? ".exe" : ""}`;
}
export function parseSha256File(contents, expectedAsset) {
const match = contents.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
if (!match) {
throw new InstallError("Checksum file is missing or malformed");
}
const digest = match[1];
const fileName = match[2];
if (!digest || !fileName) {
throw new InstallError("Checksum file is missing or malformed");
}
if (fileName !== expectedAsset) {
throw new InstallError(`Checksum file names ${fileName} instead of expected asset ${expectedAsset}`);
}
return digest.toLowerCase();
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function validateBinary(bytes, target) {
if (bytes.byteLength < 1024) {
throw new InstallError(`Downloaded asset is too small (${bytes.byteLength} bytes); the release asset may be missing`);
}
const isPe = bytes[0] === 0x4d && bytes[1] === 0x5a;
const isElf = bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46;
const magic = Buffer.from(bytes.subarray(0, 4)).readUInt32BE(0);
const isMachO = magic === 0xfeedface ||
magic === 0xfeedfacf ||
magic === 0xcefaedfe ||
magic === 0xcffaedfe;
const valid = target.startsWith("win-")
? isPe
: target.startsWith("linux-")
? isElf
: isMachO;
if (!valid) {
throw new InstallError(`Downloaded asset is not a valid ${target} executable`);
}
}
async function fetchBytes(url) {
let response;
try {
response = await fetch(url, {
headers: {
Accept: "application/octet-stream",
"User-Agent": "martin-loop-installer"
},
redirect: "follow"
});
}
catch (error) {
throw new InstallError(`Network request failed for ${url}: ${error.message}`);
}
if (!response.ok) {
throw new InstallError(`HTTP ${response.status} downloading ${url}`);
}
return new Uint8Array(await response.arrayBuffer());
}
const defaultRuntime = {
platform: platform(),
arch: arch(),
fetchBytes,
verifyExecutable(path) {
return execFileSync(path, ["--version"], {
encoding: "utf8",
timeout: 10_000,
windowsHide: true
}).trim();
},
now: () => Date.now()
};
function defaultInstallDirectory(platformName) {
if (platformName === "win32") {
return join(process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local"), "martin-loop", "bin");
}
return join(homedir(), ".local", "bin");
}
function readVersionFromLatestRelease(bytes) {
let parsed;
try {
parsed = JSON.parse(Buffer.from(bytes).toString("utf8"));
}
catch {
throw new InstallError("Latest release response was not valid JSON");
}
if (typeof parsed.tag_name !== "string" || !/^v\d+\.\d+\.\d+/.test(parsed.tag_name)) {
throw new InstallError("Latest release response did not contain a valid version tag");
}
return parsed.tag_name.slice(1);
}
function removeIfPresent(path) {
if (existsSync(path)) {
rmSync(path, { force: true });
}
}
export async function runInstall(options) {
const runtime = options.runtime ?? defaultRuntime;
const target = nativeTarget(runtime.platform, runtime.arch);
const assetName = nativeAssetName(target);
const installDirectory = options.dir ?? defaultInstallDirectory(runtime.platform);
const extension = target.startsWith("win-") ? ".exe" : "";
const installPath = join(installDirectory, `${PRODUCT_NAME}${extension}`);
const aliasPath = join(installDirectory, `martin${extension}`);
const nonce = `${process.pid}-${runtime.now()}`;
const stagedPath = join(installDirectory, `.${PRODUCT_NAME}.${nonce}.stage${extension}`);
const stagedAliasPath = join(installDirectory, `.martin.${nonce}.stage${extension}`);
const backupPath = join(installDirectory, `.${PRODUCT_NAME}.${nonce}.backup${extension}`);
const aliasBackupPath = join(installDirectory, `.martin.${nonce}.backup${extension}`);
const version = options.version ??
readVersionFromLatestRelease(await runtime.fetchBytes(`https://api.github.com/repos/${RELEASE_REPOSITORY}/releases/latest`));
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new InstallError(`Invalid release version: ${version}`);
}
const releaseBase = `https://github.com/${RELEASE_REPOSITORY}/releases/download/v${version}`;
const assetUrl = `${releaseBase}/${assetName}`;
const checksumUrl = `${assetUrl}.sha256`;
const assetBytes = await runtime.fetchBytes(assetUrl);
const checksumBytes = await runtime.fetchBytes(checksumUrl);
const expected = parseSha256File(Buffer.from(checksumBytes).toString("utf8"), assetName);
const actual = sha256(assetBytes);
if (actual !== expected) {
throw new InstallError(`Checksum mismatch for ${assetName}`);
}
validateBinary(assetBytes, target);
mkdirSync(installDirectory, { recursive: true });
writeFileSync(stagedPath, assetBytes, { mode: 0o755, flag: "wx" });
if (runtime.platform !== "win32") {
chmodSync(stagedPath, 0o755);
}
try {
runtime.verifyExecutable(stagedPath);
}
catch (error) {
removeIfPresent(stagedPath);
throw new InstallError(`Downloaded executable failed verification: ${error.message}`);
}
const hadInstall = existsSync(installPath);
const hadAlias = existsSync(aliasPath);
try {
if (hadInstall)
renameSync(installPath, backupPath);
if (hadAlias)
renameSync(aliasPath, aliasBackupPath);
renameSync(stagedPath, installPath);
if (runtime.platform === "win32") {
copyFileSync(installPath, stagedAliasPath);
}
else {
symlinkSync(installPath, stagedAliasPath);
}
renameSync(stagedAliasPath, aliasPath);
runtime.verifyExecutable(installPath);
}
catch (error) {
removeIfPresent(stagedPath);
removeIfPresent(stagedAliasPath);
removeIfPresent(aliasPath);
removeIfPresent(installPath);
if (hadInstall && existsSync(backupPath))
renameSync(backupPath, installPath);
if (hadAlias && existsSync(aliasBackupPath))
renameSync(aliasBackupPath, aliasPath);
throw new InstallError(`Native install failed and was rolled back: ${error.message}`);
}
removeIfPresent(aliasBackupPath);
return {
version,
installPath,
aliasPath,
...(hadInstall && existsSync(backupPath) ? { backupPath } : {}),
target,
assetName
};
}
export function verifyInstalledNativeBinary(path) {
if (!existsSync(path)) {
throw new InstallError(`Installed binary not found: ${path}`);
}
const bytes = readFileSync(path);
return {
path,
size: statSync(path).size,
sha256: sha256(bytes)
};
}
//# sourceMappingURL=install.js.map
export declare function shouldShowRating(run: number, lastAt: number): boolean;
export declare function shouldShowFeatureRequest(run: number, lastAt: number): boolean;
export declare function shouldShowDesignPartner(run: number, lastAt: number, converted: boolean): boolean;
export declare function maybeShowFeedbackFlow(runCount: number): Promise<void>;
// SPDX-FileCopyrightText: MartinLoop contributors
//
// SPDX-License-Identifier: Apache-2.0
import { appendFileSync } from "node:fs";
import * as readline from "node:readline";
import { martinFilePath, ensureMartinDir } from "./home-dir.js";
import { readRunStats, writeRunStats } from "./run-stats.js";
const FEEDBACK_FILE = martinFilePath("feedback.jsonl");
const DESIGN_PARTNER_FILE = martinFilePath("design-partners.jsonl");
const WEB3FORMS_KEY = process.env["MARTIN_WEB3FORMS_KEY"] ?? "f77cbe5d-3993-4b09-b8df-c6da94523ae6";
const ENDPOINT = "https://api.web3forms.com/submit";
// ─── Trigger logic ────────────────────────────────────────────────────────────
export function shouldShowRating(run, lastAt) {
if (run < 10)
return false;
return (run - lastAt) >= 10;
}
export function shouldShowFeatureRequest(run, lastAt) {
if (run < 20)
return false;
return (run - lastAt) >= 20;
}
export function shouldShowDesignPartner(run, lastAt, converted) {
if (converted)
return false;
if (run < 10)
return false;
if (lastAt === 0 && run >= 30)
return true;
return lastAt > 0 && (run - lastAt) >= 30;
}
// ─── Main entry point ─────────────────────────────────────────────────────────
export async function maybeShowFeedbackFlow(runCount) {
const stats = readRunStats();
if (stats.feedbackOptOut)
return;
let askedSomething = false;
let rating = 0;
let highEngagement = false;
if (shouldShowRating(runCount, stats.lastFeedbackAtRun)) {
rating = await showRatingPrompt(runCount, stats.martinVersion, stats.totalSuccessfulRuns);
stats.lastFeedbackAtRun = runCount;
askedSomething = true;
highEngagement = rating >= 4;
}
if (shouldShowFeatureRequest(runCount, stats.lastFeatureRequestAtRun)) {
await showFeatureRequestPrompt(runCount, stats.martinVersion);
stats.lastFeatureRequestAtRun = runCount;
askedSomething = true;
}
const triggerDesignPartner = (highEngagement && shouldShowDesignPartner(runCount, stats.lastDesignPartnerAskAtRun, stats.designPartnerConverted))
|| shouldShowDesignPartner(runCount, stats.lastDesignPartnerAskAtRun, stats.designPartnerConverted);
if (triggerDesignPartner) {
const converted = await showDesignPartnerPrompt(runCount, stats.martinVersion);
stats.lastDesignPartnerAskAtRun = runCount;
if (converted)
stats.designPartnerConverted = true;
askedSomething = true;
}
if (askedSomething)
writeRunStats(stats);
if (askedSomething)
console.log("");
}
// ─── Rating prompt ────────────────────────────────────────────────────────────
async function showRatingPrompt(runCount, version, totalRuns) {
console.log("");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" 30 seconds of feedback — genuinely helps");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("");
console.log(" How would you rate MartinLoop so far?");
console.log("");
console.log(" 1 Not working for me");
console.log(" 2 Has potential but needs work");
console.log(" 3 Solid, does the job");
console.log(" 4 Really useful, part of my workflow");
console.log(" 5 Can't imagine working without it");
console.log("");
process.stdout.write(" [1–5 — key registers instantly, Enter to skip]\n > ");
const ratingKey = await readSingleKeypress();
process.stdout.write(`${ratingKey}\n`);
const rating = parseInt(ratingKey, 10);
if (!ratingKey || ratingKey === "\r" || ratingKey === "\n" || isNaN(rating) || rating < 1 || rating > 5) {
console.log("");
return 0;
}
let followUp = "";
if (rating >= 4) {
console.log("");
process.stdout.write(" What's the single most valuable thing MartinLoop does for you?\n (or Enter to skip)\n > ");
followUp = await readSingleLine();
console.log("");
console.log(" 💚 That's exactly what it's built for. Thank you.");
}
else {
console.log("");
process.stdout.write(" What's the biggest thing holding it back for you?\n (or Enter to skip)\n > ");
followUp = await readSingleLine();
console.log("");
console.log(" Noted — that feedback goes directly to the team.");
console.log(" We'll use it. Thank you for being honest.");
}
const entry = {
type: "rating",
runCount,
totalRuns,
rating,
followUp: followUp.trim(),
martinVersion: version,
platform: process.platform,
ts: new Date().toISOString()
};
writeLocal(FEEDBACK_FILE, entry);
await sendToWeb3Forms({
subject: `MartinLoop Feedback — ${rating}/5 — Run #${runCount}`,
rating,
comment: followUp.trim() || "(no comment)",
run_count: runCount,
total_runs: totalRuns,
martin_version: version,
platform: process.platform
});
return rating;
}
// ─── Feature request prompt ───────────────────────────────────────────────────
async function showFeatureRequestPrompt(runCount, version) {
console.log("");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" One question about what comes next");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("");
process.stdout.write(" What's one thing MartinLoop doesn't do yet\n" +
" that would make you recommend it to your team?\n" +
" (or Enter to skip)\n > ");
const feature = await readSingleLine();
if (!feature.trim())
return;
console.log("");
console.log(" Logged. This goes straight to the roadmap.");
writeLocal(FEEDBACK_FILE, {
type: "feature_request",
runCount,
feature: feature.trim(),
martinVersion: version,
platform: process.platform,
ts: new Date().toISOString()
});
await sendToWeb3Forms({
subject: `MartinLoop Feature Request — Run #${runCount}`,
feature_request: feature.trim(),
run_count: runCount,
martin_version: version,
platform: process.platform
});
}
// ─── Design partner prompt ────────────────────────────────────────────────────
async function showDesignPartnerPrompt(runCount, version) {
console.log("");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" One more thing — if you're open to it");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("");
console.log(" We're building the growth version of MartinLoop —");
console.log(" a full governance platform with ROI intelligence,");
console.log(" team dashboards, spend forecasting, and compliance");
console.log(" audit trails for engineering and finance teams.");
console.log("");
console.log(" Design partners get:");
console.log(" → Beta access before public release");
console.log(" → Direct input on the roadmap");
console.log(" → Founder-level support and onboarding");
console.log(" → Locked pricing before we go paid");
console.log("");
process.stdout.write(" Are you open to being a design partner? [Y/n]\n > ");
const answer = await readSingleLine();
const normalized = answer.trim().toLowerCase();
// [Y/n] — Enter or "y" = yes; anything else = no
if (normalized !== "" && normalized !== "y") {
console.log("");
console.log(" No problem — appreciate you using MartinLoop.");
return false;
}
console.log("");
process.stdout.write(" First name:\n > ");
const firstName = await readSingleLine();
process.stdout.write(" Last name:\n > ");
const lastName = await readSingleLine();
process.stdout.write(" Work email:\n > ");
const email = await readSingleLine();
// Basic email validation — skip signup if format is invalid
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email.trim())) {
console.log("");
console.log(" Invalid email — we weren't able to save your signup.");
return false;
}
process.stdout.write(" Company name:\n > ");
const company = await readSingleLine();
console.log("");
console.log(" Last one — what would you expect to pay for a");
console.log(" full governance platform for your engineering team?");
console.log("");
console.log(" A Under $100 / month");
console.log(" B $100 – $500 / month");
console.log(" C $500 – $2,000 / month");
console.log(" D $2,000+ / month");
console.log(" E Prefer not to say");
console.log("");
process.stdout.write(" [A/B/C/D/E]\n > ");
const pricingInput = await readSingleLine();
const pricingMap = {
a: "Under $100/month",
b: "$100–$500/month",
c: "$500–$2,000/month",
d: "$2,000+/month",
e: "Prefer not to say"
};
const pricing = pricingMap[pricingInput.trim().toLowerCase()] ?? "Not answered";
console.log("");
console.log(" ✅ You're in.");
console.log("");
console.log(` We'll reach out to ${firstName.trim()} at ${email.trim()} soon.`);
console.log(" In the meantime, keep shipping — every governed");
console.log(" run makes the platform smarter.");
console.log("");
console.log(" — Keesan & Gobi, MartinLoop");
const entry = {
type: "design_partner",
runCount,
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
company: company.trim(),
pricingExpectation: pricing,
martinVersion: version,
platform: process.platform,
ts: new Date().toISOString()
};
writeLocal(DESIGN_PARTNER_FILE, entry);
await sendToWeb3Forms({
subject: `MartinLoop Design Partner — ${firstName.trim()} ${lastName.trim()} @ ${company.trim()}`,
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim(),
company: company.trim(),
pricing_expectation: pricing,
run_count: runCount,
martin_version: version,
platform: process.platform
});
return true;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function writeLocal(file, entry) {
ensureMartinDir();
appendFileSync(file, JSON.stringify(entry) + "\n", "utf-8");
}
async function sendToWeb3Forms(data) {
if (WEB3FORMS_KEY.trim().length === 0)
return; // Opt-out: set MARTIN_WEB3FORMS_KEY="" to disable
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_key: WEB3FORMS_KEY, from_name: "MartinLoop CLI", ...data }),
signal: controller.signal
});
if (!res.ok)
throw new Error(`HTTP ${res.status}`);
}
finally {
clearTimeout(timeout);
}
}
catch {
// Silent fail — data already written locally
}
}
function readSingleLine() {
return new Promise((resolve) => {
if (!process.stdin.isTTY) {
resolve("");
return;
}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
const timeout = setTimeout(() => { rl.close(); resolve(""); }, 30_000);
rl.once("line", (line) => { clearTimeout(timeout); rl.close(); resolve(line); });
rl.once("close", () => { clearTimeout(timeout); resolve(""); });
});
}
function readSingleKeypress() {
return new Promise((resolve) => {
const stdin = process.stdin;
if (!stdin.isTTY) {
resolve("");
return;
}
const prev = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding("utf-8");
const timeout = setTimeout(() => { cleanup(); resolve(""); }, 30_000);
const onData = (key) => {
if (key === "\u0003") {
cleanup();
process.exit(0);
}
cleanup();
resolve(key);
};
const cleanup = () => {
clearTimeout(timeout);
stdin.removeListener("data", onData);
stdin.setRawMode(prev ?? false);
stdin.pause();
};
stdin.on("data", onData);
});
}
//# sourceMappingURL=feedback.js.map
export interface RunStats {
totalSuccessfulRuns: number;
lastStarPromptAtRun: number;
lastFeedbackAtRun: number;
lastFeatureRequestAtRun: number;
lastDesignPartnerAskAtRun: number;
designPartnerConverted: boolean;
starPromptOptOut: boolean;
feedbackOptOut: boolean;
telemetryOptIn: boolean | null;
martinVersion: string;
}
export declare function readRunStats(): RunStats;
export declare function writeRunStats(stats: RunStats): void;
export declare function recordSuccessfulRun(version: string): RunStats;
// SPDX-FileCopyrightText: MartinLoop contributors
//
// SPDX-License-Identifier: Apache-2.0
import { readFileSync, writeFileSync } from "node:fs";
import { martinFilePath, ensureMartinDir } from "./home-dir.js";
const STATS_FILE = martinFilePath("run-stats.json");
function defaults() {
return {
totalSuccessfulRuns: 0,
lastStarPromptAtRun: 0,
lastFeedbackAtRun: 0,
lastFeatureRequestAtRun: 0,
lastDesignPartnerAskAtRun: 0,
designPartnerConverted: false,
starPromptOptOut: false,
feedbackOptOut: false,
telemetryOptIn: null,
martinVersion: ""
};
}
export function readRunStats() {
ensureMartinDir();
try {
return { ...defaults(), ...JSON.parse(readFileSync(STATS_FILE, "utf-8")) };
}
catch {
return defaults();
}
}
export function writeRunStats(stats) {
ensureMartinDir();
writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2), "utf-8");
}
export function recordSuccessfulRun(version) {
const stats = readRunStats();
stats.totalSuccessfulRuns += 1;
stats.martinVersion = version;
writeRunStats(stats);
return stats;
}
//# sourceMappingURL=run-stats.js.map
export declare function shouldShowStarPrompt(runCount: number, lastShownAt: number): boolean;
export declare function maybeShowStarPrompt(runCount: number): Promise<void>;
export declare function showInlineStarCta(): Promise<void>;
// SPDX-FileCopyrightText: MartinLoop contributors
//
// SPDX-License-Identifier: Apache-2.0
import { readRunStats, writeRunStats } from "./run-stats.js";
const STAR_URL = "https://github.com/Keesan12/martin-loop";
export function shouldShowStarPrompt(runCount, lastShownAt) {
if (runCount === 2)
return true;
if (runCount > 2 && (runCount - lastShownAt) >= 25)
return true;
return false;
}
export async function maybeShowStarPrompt(runCount) {
const stats = readRunStats();
if (stats.starPromptOptOut)
return;
if (!shouldShowStarPrompt(runCount, stats.lastStarPromptAtRun))
return;
const isFirstTime = runCount === 2;
console.log("");
if (isFirstTime) {
console.log("✨ Two runs down. MartinLoop is doing its job.");
console.log("");
console.log(" It's open source and stays free because of the team");
console.log(" behind it. If it's earning a place in your workflow, a ⭐");
console.log(" on GitHub helps other developers find it:");
}
else {
console.log(`✨ Run #${runCount} with MartinLoop — genuinely appreciate it.`);
console.log(" A quick ⭐ on GitHub helps other devs find this project:");
}
console.log(`\n ${STAR_URL}\n`);
console.log(" [Enter] open in browser [s] skip [n] never ask again");
process.stdout.write(" > ");
const key = await readSingleKeypress();
console.log("");
if (key === "n" || key === "N") {
stats.starPromptOptOut = true;
console.log(" Got it — won't ask again. To re-enable: martin config set star-prompt on\n");
}
else if (key === "\r" || key === "\n") {
try {
const { exec } = await import("node:child_process");
const cmd = process.platform === "win32" ? `start "" "${STAR_URL}"`
: process.platform === "darwin" ? `open "${STAR_URL}"`
: `xdg-open "${STAR_URL}"`;
exec(cmd);
console.log(" Opening GitHub... ⭐\n");
}
catch {
console.log(` Open this in your browser: ${STAR_URL}\n`);
}
}
stats.lastStarPromptAtRun = runCount;
writeRunStats(stats);
}
export async function showInlineStarCta() {
if (!process.stdout.isTTY || !process.stdin.isTTY)
return;
console.log("");
console.log("─────────────────────────────────────────────");
console.log("⭐ MartinLoop saved you from a runaway bill.");
console.log(` ${STAR_URL}`);
console.log("");
console.log(" [Enter] open in browser [s] skip");
process.stdout.write(" > ");
const key = await readSingleKeypress();
console.log("");
if (key === "\r" || key === "\n") {
try {
const { exec } = await import("node:child_process");
const cmd = process.platform === "win32" ? `start "" "${STAR_URL}"`
: process.platform === "darwin" ? `open "${STAR_URL}"`
: `xdg-open "${STAR_URL}"`;
exec(cmd);
console.log(" Opening GitHub... ⭐");
}
catch {
console.log(` Open this in your browser: ${STAR_URL}`);
}
}
console.log("─────────────────────────────────────────────");
}
async function readSingleKeypress() {
return new Promise((resolve) => {
const stdin = process.stdin;
if (!stdin.isTTY) {
resolve("");
return;
}
const prev = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding("utf-8");
const timeout = setTimeout(() => { cleanup(); resolve(""); }, 15_000);
const onData = (key) => {
if (key === "\u0003") {
cleanup();
process.exit(0);
}
cleanup();
resolve(key);
};
const cleanup = () => {
clearTimeout(timeout);
stdin.removeListener("data", onData);
stdin.setRawMode(prev ?? false);
stdin.pause();
};
stdin.on("data", onData);
});
}
//# sourceMappingURL=star-prompt.js.map

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display