@decidio/sdk 0.4.0 → 0.4.3
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/CHANGELOG.md +208 -0
- package/README.md +21 -6
- package/dist/adapters/_gate.d.ts +6 -6
- package/dist/adapters/_gate.js +1 -1
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +791 -171
- package/dist/inngest.d.ts +14 -6
- package/dist/resume.d.ts +7 -1
- package/dist/resume.js +61 -11
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join as joinPath } from "node:path";
|
|
4
6
|
import { createServer } from "node:http";
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
8
|
import { createInterface } from "node:readline";
|
|
9
|
+
import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
|
|
10
|
+
import { realpathSync as realPath } from "node:fs";
|
|
7
11
|
import { createRequire } from "node:module";
|
|
8
|
-
import { generateKeypair, assertSafeTransport } from "./signing.js";
|
|
12
|
+
import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
|
|
9
13
|
import { hardenFile } from "./resume.js";
|
|
10
|
-
import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
|
|
14
|
+
import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, didFromPrivateKey } from "./index.js";
|
|
11
15
|
import { verifyReceipt, runVerify } from "@decidio/verify";
|
|
12
16
|
const VERSION = (() => {
|
|
13
17
|
try {
|
|
@@ -39,9 +43,10 @@ function readEnvFile() {
|
|
|
39
43
|
return out;
|
|
40
44
|
}
|
|
41
45
|
const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
|
|
46
|
+
const helpFlagAnywhere = (argv) => { const dd = argv.indexOf("--"); return (dd >= 0 ? argv.slice(0, dd) : argv).some((s) => s === "--help" || s === "-h"); };
|
|
42
47
|
const ARGV_TAIL = process.argv.slice(2);
|
|
43
48
|
const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].includes(ARGV_TAIL[0])
|
|
44
|
-
|| wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]);
|
|
49
|
+
|| wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]) || helpFlagAnywhere(ARGV_TAIL);
|
|
45
50
|
if (!OFFLINE_CMD) {
|
|
46
51
|
readEnvFile();
|
|
47
52
|
if (envUnreadable) {
|
|
@@ -50,6 +55,7 @@ if (!OFFLINE_CMD) {
|
|
|
50
55
|
process.exit(1);
|
|
51
56
|
}
|
|
52
57
|
}
|
|
58
|
+
const SHELL_ENV = { ...process.env };
|
|
53
59
|
if (!OFFLINE_CMD)
|
|
54
60
|
for (const [k, v] of Object.entries(readEnvFile())) {
|
|
55
61
|
if (k === "DECIDIO_SESSION_TOKEN") {
|
|
@@ -65,7 +71,7 @@ const ENV_PATH = ".env";
|
|
|
65
71
|
const log = (...a) => console.log(...a);
|
|
66
72
|
const err = (...a) => console.error(...a);
|
|
67
73
|
let hostAnnounced = false;
|
|
68
|
-
async function api(path, init = {}, bearer) {
|
|
74
|
+
async function api(path, init = {}, bearer, soft = false) {
|
|
69
75
|
if (!hostAnnounced) {
|
|
70
76
|
hostAnnounced = true;
|
|
71
77
|
err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
@@ -90,6 +96,8 @@ async function api(path, init = {}, bearer) {
|
|
|
90
96
|
res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
|
|
91
97
|
}
|
|
92
98
|
catch (e) {
|
|
99
|
+
if (soft)
|
|
100
|
+
return { status: 0, body: null };
|
|
93
101
|
err(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
|
|
94
102
|
if (API.includes("localhost")) {
|
|
95
103
|
err(` You're pointed at localhost — for the hosted sandbox run:`);
|
|
@@ -246,6 +254,33 @@ function journalRows() {
|
|
|
246
254
|
}
|
|
247
255
|
return rows;
|
|
248
256
|
}
|
|
257
|
+
function credentialJtisOnDisk() {
|
|
258
|
+
const out = new Set();
|
|
259
|
+
for (const f of [".env", ".env.quickstart"]) {
|
|
260
|
+
if (!existsSync(f))
|
|
261
|
+
continue;
|
|
262
|
+
let text = "";
|
|
263
|
+
try {
|
|
264
|
+
text = readFileSync(f, "utf8");
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
for (const m of text.matchAll(/^\s*DECIDIO_(?:ORPHANED_)?TOKEN_JTI=(.+)$/gm))
|
|
270
|
+
for (const j of m[1].split(","))
|
|
271
|
+
if (j.trim())
|
|
272
|
+
out.add(j.trim());
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
function journalOwed() {
|
|
277
|
+
const held = credentialJtisOnDisk();
|
|
278
|
+
return journalOutstanding().filter((j) => j.label === "quickstart" || (!j.persisted && !held.has(j.jti)));
|
|
279
|
+
}
|
|
280
|
+
function journalUnheld() {
|
|
281
|
+
const held = credentialJtisOnDisk();
|
|
282
|
+
return journalOutstanding().filter((j) => j.label !== "quickstart" && !held.has(j.jti) && j.persisted);
|
|
283
|
+
}
|
|
249
284
|
const REFUSE_MINT_CORRUPT_JOURNAL = (e) => `✘ the token recovery journal cannot be trusted (${e.message}) - it may hold the id of a token that is still live. Refusing to mint on top of uncertain history. Inspect the file, revoke anything it names (npx @decidio/sdk tokens list --mine), and repair or move it aside; every byte is left as found. Nothing was minted.`;
|
|
250
285
|
function journalProbe() {
|
|
251
286
|
try {
|
|
@@ -263,16 +298,29 @@ function journalProbe() {
|
|
|
263
298
|
}
|
|
264
299
|
}
|
|
265
300
|
function journalMinted(jti, agentId, label) { journalAppend({ event: "minted", jti, agentId, label, apiUrl: API }); }
|
|
266
|
-
function journalRetire(jti, how) {
|
|
267
|
-
|
|
301
|
+
function journalRetire(jti, how) {
|
|
302
|
+
try {
|
|
303
|
+
journalAppend({ event: "retired", jti, how });
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
err(how === "persisted"
|
|
308
|
+
? ` ⚠ could not note in ${JOURNAL_PATH} that token ${jti} was written to its credential file (${e?.message ?? e}). The credential is safe in the file; doctor will list the token as live and held.`
|
|
309
|
+
: ` ⚠ could not note in ${JOURNAL_PATH} that token ${jti} was revoked (${e?.message ?? e}). It IS revoked; a later cleanup will find the server already agrees and retire the row.`);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
268
312
|
}
|
|
269
|
-
catch { } }
|
|
270
313
|
function journalOutstanding() {
|
|
271
314
|
const minted = new Map();
|
|
272
315
|
for (const e of journalRows()) {
|
|
273
316
|
if (e.event === "minted")
|
|
274
317
|
minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
|
|
275
|
-
if (e.event === "retired")
|
|
318
|
+
if (e.event === "retired" && e.how === "persisted") {
|
|
319
|
+
const m = minted.get(e.jti);
|
|
320
|
+
if (m)
|
|
321
|
+
m.persisted = true;
|
|
322
|
+
}
|
|
323
|
+
if (e.event === "retired" && e.how === "revoked")
|
|
276
324
|
minted.delete(e.jti);
|
|
277
325
|
}
|
|
278
326
|
return [...minted.values()];
|
|
@@ -337,66 +385,247 @@ function ensureGitignore() {
|
|
|
337
385
|
appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
|
|
338
386
|
log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
|
|
339
387
|
}
|
|
388
|
+
function recordKindOf(credential) {
|
|
389
|
+
try {
|
|
390
|
+
const rc = credential?.credentialSubject?.recordCanonical;
|
|
391
|
+
const rec = typeof rc === "string" ? JSON.parse(rc) : rc;
|
|
392
|
+
return typeof rec?.recordKind === "string" ? rec.recordKind : undefined;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function sessionReminder() {
|
|
399
|
+
log(` ⚠ agent code launched from THIS terminal inherits your sign-in — run logout first (it also ends on close or cleanup); if you`);
|
|
400
|
+
log(` exported DECIDIO_SESSION_TOKEN, unset it ${dim("(bash/zsh: unset DECIDIO_SESSION_TOKEN · PowerShell: Remove-Item Env:DECIDIO_SESSION_TOKEN)")}`);
|
|
401
|
+
}
|
|
402
|
+
const TTY_OUT = !!process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
403
|
+
const paint = (code, s) => (TTY_OUT ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
404
|
+
const bold = (s) => paint("1", s);
|
|
405
|
+
const dim = (s) => paint("2", s);
|
|
406
|
+
const link = (url) => (TTY_OUT ? `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\` : url);
|
|
407
|
+
const FRAME_WIDTH = 84;
|
|
408
|
+
const visibleLength = (s) => s.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\/g, "").length;
|
|
409
|
+
function frameWidth(rows) {
|
|
410
|
+
const cols = (process.stdout.columns ?? 0) > 20 ? process.stdout.columns : 120;
|
|
411
|
+
const widest = rows.reduce((m, r) => Math.max(m, visibleLength(r)), 0);
|
|
412
|
+
return Math.max(FRAME_WIDTH, Math.min(widest + 4, cols - 1));
|
|
413
|
+
}
|
|
414
|
+
function block(n, title, rows, tone) {
|
|
415
|
+
const tint = tone === "choice" ? (process.env.TERM?.includes("256") || process.env.COLORTERM || process.env.WT_SESSION ? "38;5;208" : "33") : tone === "good" ? "32" : tone === "bad" ? "31" : null;
|
|
416
|
+
const f = (s) => (tint ? paint(tint, s) : s);
|
|
417
|
+
const width = frameWidth(rows);
|
|
418
|
+
const inner = width - 4;
|
|
419
|
+
const head = `┌ ${n} ${title} `;
|
|
420
|
+
log(f(head + "─".repeat(Math.max(1, width - visibleLength(head) - 1)) + "┐"));
|
|
421
|
+
for (const row of rows) {
|
|
422
|
+
const pieces = [];
|
|
423
|
+
if (visibleLength(row) <= inner)
|
|
424
|
+
pieces.push(row);
|
|
425
|
+
else {
|
|
426
|
+
let cur = "";
|
|
427
|
+
for (const w of row.split(" ")) {
|
|
428
|
+
if (cur && visibleLength(cur) + 1 + visibleLength(w) > inner) {
|
|
429
|
+
pieces.push(cur);
|
|
430
|
+
cur = " ".repeat(Math.min(13, Math.max(0, inner - 24))) + w;
|
|
431
|
+
}
|
|
432
|
+
else
|
|
433
|
+
cur = cur ? `${cur} ${w}` : w;
|
|
434
|
+
}
|
|
435
|
+
if (cur)
|
|
436
|
+
pieces.push(cur);
|
|
437
|
+
}
|
|
438
|
+
for (const piece of pieces)
|
|
439
|
+
log(`${f("│")} ${piece}${TTY_OUT ? "\x1b[0m" : ""}${" ".repeat(Math.max(0, inner - visibleLength(piece)))} ${f("│")}`);
|
|
440
|
+
}
|
|
441
|
+
log(f("└" + "─".repeat(width - 2) + "┘"));
|
|
442
|
+
}
|
|
443
|
+
const APP_URL = API.replace("decidio-api.onrender.com", "app.decidioai.com");
|
|
444
|
+
let stdinReadAbandoned = false;
|
|
445
|
+
let EXPLAIN = false;
|
|
446
|
+
let currentRl = null;
|
|
447
|
+
const APPROVE_WORDS = new Set(["y", "yes", "approve", "approved"]);
|
|
448
|
+
const REJECT_WORDS = new Set(["n", "no", "reject", "rejected", "deny", "denied"]);
|
|
449
|
+
export function approvalAnswer(answer) {
|
|
450
|
+
const a = String(answer ?? "").trim().toLowerCase();
|
|
451
|
+
if (APPROVE_WORDS.has(a))
|
|
452
|
+
return "approve";
|
|
453
|
+
if (REJECT_WORDS.has(a))
|
|
454
|
+
return "reject";
|
|
455
|
+
return "again";
|
|
456
|
+
}
|
|
340
457
|
function ask(question, mute = false) {
|
|
458
|
+
if (process.stdin.readableEnded || process.stdin.destroyed)
|
|
459
|
+
return Promise.resolve("");
|
|
341
460
|
return new Promise((resolve) => {
|
|
342
461
|
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
462
|
+
currentRl = rl;
|
|
343
463
|
if (mute) {
|
|
344
464
|
const anyRl = rl;
|
|
345
465
|
const write = anyRl._writeToOutput?.bind(rl);
|
|
346
466
|
anyRl._writeToOutput = (s) => { if (s.includes(question))
|
|
347
467
|
write?.(s); };
|
|
348
468
|
}
|
|
349
|
-
rl.
|
|
350
|
-
|
|
469
|
+
rl.once("close", () => { if (currentRl === rl)
|
|
470
|
+
currentRl = null; resolve(""); });
|
|
471
|
+
rl.question(question, (answer) => { resolve(answer.trim()); rl.close(); if (mute)
|
|
472
|
+
process.stderr.write("\n"); });
|
|
351
473
|
});
|
|
352
474
|
}
|
|
353
|
-
|
|
475
|
+
function terminalId() {
|
|
476
|
+
for (const k of ["WT_SESSION", "VSCODE_IPC_HOOK_CLI", "TERM_SESSION_ID", "ITERM_SESSION_ID", "TMUX_PANE", "KONSOLE_DBUS_SESSION", "GNOME_TERMINAL_SCREEN", "WINDOWID"]) {
|
|
477
|
+
const v = process.env[k];
|
|
478
|
+
if (v)
|
|
479
|
+
return `${k}=${v}`;
|
|
480
|
+
}
|
|
481
|
+
return "";
|
|
482
|
+
}
|
|
483
|
+
function sessionDir() {
|
|
484
|
+
const base = process.platform === "win32" ? (process.env.LOCALAPPDATA ?? joinPath(homedir(), "AppData", "Local")) : (process.env.XDG_CONFIG_HOME ?? joinPath(homedir(), ".config"));
|
|
485
|
+
return joinPath(base, "decidio");
|
|
486
|
+
}
|
|
487
|
+
const sessionFile = () => joinPath(sessionDir(), "session.json");
|
|
488
|
+
function jwtExpiry(token) {
|
|
354
489
|
try {
|
|
355
|
-
|
|
490
|
+
const p = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
491
|
+
return typeof p?.exp === "number" ? new Date(p.exp * 1000) : null;
|
|
356
492
|
}
|
|
357
493
|
catch {
|
|
358
|
-
err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
|
|
359
|
-
err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
|
|
360
494
|
return null;
|
|
361
495
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
496
|
+
}
|
|
497
|
+
function saveSession(token, email) {
|
|
498
|
+
mkdirSync(sessionDir(), { recursive: true });
|
|
499
|
+
const s = { v: 1, api: API, token, email, expiresAt: (jwtExpiry(token) ?? new Date(Date.now() + 30 * 24 * 3600 * 1000)).toISOString(), terminal: terminalId(), createdAt: new Date().toISOString() };
|
|
500
|
+
writeSecretFile(sessionFile(), JSON.stringify(s) + "\n");
|
|
501
|
+
return s;
|
|
502
|
+
}
|
|
503
|
+
function loadSession() {
|
|
504
|
+
const f = sessionFile();
|
|
505
|
+
if (!existsSync(f))
|
|
506
|
+
return null;
|
|
507
|
+
let s;
|
|
508
|
+
try {
|
|
509
|
+
s = JSON.parse(readFileSync(f, "utf8"));
|
|
510
|
+
}
|
|
511
|
+
catch (e) {
|
|
512
|
+
err(`⚠ the stored sign-in (${f}) could not be read (${e?.message ?? e}) - removing it; sign in again with: npx @decidio/sdk login`);
|
|
513
|
+
endSession();
|
|
370
514
|
return null;
|
|
371
515
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
516
|
+
if (!s || s.v !== 1 || typeof s.token !== "string" || s.api !== API)
|
|
517
|
+
return null;
|
|
518
|
+
if (new Date(String(s.expiresAt)).getTime() <= Date.now()) {
|
|
519
|
+
endSession();
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const here = terminalId();
|
|
523
|
+
if (s.terminal && s.terminal !== here)
|
|
524
|
+
return null;
|
|
525
|
+
return s;
|
|
526
|
+
}
|
|
527
|
+
function endSession() {
|
|
528
|
+
const f = sessionFile();
|
|
529
|
+
if (!existsSync(f))
|
|
530
|
+
return false;
|
|
531
|
+
try {
|
|
532
|
+
rmSync(f);
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
catch (e) {
|
|
536
|
+
err(`⚠ could not remove the stored sign-in (${f}: ${e?.message ?? e}) - remove it by hand.`);
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async function loginFlow(purpose, emailArg) {
|
|
541
|
+
log(purpose === "Signing in" ? `Sign in once — 30 days, or until this terminal closes, logout, or cleanup.` : `${purpose} needs your Decidio workspace sign-in — once: 30 days, or until this terminal closes, logout, or cleanup.`);
|
|
542
|
+
const email = emailArg ?? await ask(" email: ");
|
|
543
|
+
if (!email) {
|
|
544
|
+
log(" ✘ no email given - re-run when you have it.");
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
const password = await ask(" password (Enter = emailed sign-in link): ", true);
|
|
548
|
+
if (password) {
|
|
549
|
+
const r = await api("/demo/login", { method: "POST", body: JSON.stringify({ email, password }) }, "");
|
|
550
|
+
if (r.status >= 200 && r.status < 300 && r.body?.token) {
|
|
551
|
+
const s = saveSession(String(r.body.token), email);
|
|
552
|
+
log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
|
|
553
|
+
return s.token;
|
|
554
|
+
}
|
|
555
|
+
log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the emailed link works for every account:`);
|
|
556
|
+
}
|
|
557
|
+
const req = await api("/demo/login-link", { method: "POST", body: JSON.stringify({ email }) }, "");
|
|
558
|
+
if (!(req.status >= 200 && req.status < 300)) {
|
|
559
|
+
log(` ✘ could not request a sign-in email (${req.status})${req.body?.error ? ` — ${req.body.error}` : ""}. Check the address and re-run.`);
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
log(` ✔ sign-in email requested for ${email} — check your inbox and spam; paste the link (or its login= code; single-use, 15 minutes)`);
|
|
563
|
+
log(dim(` invite-only lab: a link arrives only for an invited address — not invited yet? request access: decidioai.com/#early-access`));
|
|
564
|
+
const pasted = await ask(" link or code: ");
|
|
387
565
|
if (!pasted) {
|
|
388
|
-
log(" ✘ nothing pasted — re-run when you have the
|
|
566
|
+
log(" ✘ nothing pasted — re-run when you have the email.");
|
|
389
567
|
return null;
|
|
390
568
|
}
|
|
391
569
|
const m = pasted.match(/[?&]login=([^&\s]+)/);
|
|
392
570
|
const linkToken = m ? decodeURIComponent(m[1]) : pasted;
|
|
393
571
|
const r = await api("/demo/login-consume", { method: "POST", body: JSON.stringify({ token: linkToken }) }, "");
|
|
394
|
-
if (r.status >= 200 && r.status < 300 && r.body?.token)
|
|
395
|
-
|
|
572
|
+
if (r.status >= 200 && r.status < 300 && r.body?.token) {
|
|
573
|
+
const s = saveSession(String(r.body.token), email);
|
|
574
|
+
log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
|
|
575
|
+
return s.token;
|
|
576
|
+
}
|
|
396
577
|
log(` ✘ that link didn't work (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}.`);
|
|
397
578
|
log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
|
|
398
579
|
return null;
|
|
399
580
|
}
|
|
581
|
+
async function acquireSession(purpose) {
|
|
582
|
+
try {
|
|
583
|
+
assertSafeTransport(API, true);
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
|
|
587
|
+
err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
591
|
+
return process.env.DECIDIO_SESSION_TOKEN;
|
|
592
|
+
const stored = loadSession();
|
|
593
|
+
if (stored)
|
|
594
|
+
return stored.token;
|
|
595
|
+
if (!process.stdin.isTTY) {
|
|
596
|
+
err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
|
|
597
|
+
err(` Fix: run \`npx @decidio/sdk login\` in an interactive terminal first (the sign-in is stored for this terminal),`);
|
|
598
|
+
err(` or set DECIDIO_SESSION_TOKEN. To mint one by hand: request a sign-in link, then`);
|
|
599
|
+
err(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}' # the response's "token"`);
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
return loginFlow(purpose);
|
|
603
|
+
}
|
|
604
|
+
async function cmdLogin(emailArg) {
|
|
605
|
+
try {
|
|
606
|
+
assertSafeTransport(API, true);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
|
|
610
|
+
return 1;
|
|
611
|
+
}
|
|
612
|
+
const stored = loadSession();
|
|
613
|
+
if (stored) {
|
|
614
|
+
log(`• already signed in${stored.email ? ` as ${stored.email}` : ""} on this terminal until ${stored.expiresAt.slice(0, 10)} - run \`npx @decidio/sdk logout\` first to switch accounts.`);
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
const token = await loginFlow("Signing in", emailArg);
|
|
618
|
+
return token ? 0 : 1;
|
|
619
|
+
}
|
|
620
|
+
function cmdLogout() {
|
|
621
|
+
if (endSession())
|
|
622
|
+
log("✔ signed out — the stored sign-in is removed.");
|
|
623
|
+
else
|
|
624
|
+
log("• no stored sign-in on this machine for this Decidio.");
|
|
625
|
+
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
626
|
+
log(" • DECIDIO_SESSION_TOKEN is still exported in this shell — unset it too (bash: unset DECIDIO_SESSION_TOKEN · PowerShell: Remove-Item Env:DECIDIO_SESSION_TOKEN).");
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
400
629
|
async function cmdInit(agentId, opts = {}) {
|
|
401
630
|
if (!assertNotTracked(ENV_PATH))
|
|
402
631
|
return 1;
|
|
@@ -581,6 +810,10 @@ async function cmdDoctor() {
|
|
|
581
810
|
}
|
|
582
811
|
for (const j of journalItems) {
|
|
583
812
|
const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
|
|
813
|
+
if (j.jti === env.DECIDIO_TOKEN_JTI) {
|
|
814
|
+
line(true, `token recovery journal: jti ${j.jti} is live and held by ${ENV_PATH} — it retires only when revoked (npx @decidio/sdk tokens revoke ${j.jti}; a tour token: quickstart cleanup).`);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
584
817
|
warn(`token recovery journal: jti ${j.jti} (${j.label ?? "token"}, agent ${j.agentId ?? "?"}, minted ${j.at ?? "?"} against ${j.apiUrl ?? API}) has no recorded revocation in this directory's journal — it may still be live. Revoke it, or confirm it is gone: ${where}npx @decidio/sdk quickstart cleanup (or: ${where}npx @decidio/sdk tokens revoke ${j.jti})`);
|
|
585
818
|
}
|
|
586
819
|
log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
@@ -744,6 +977,21 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
|
744
977
|
log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
|
|
745
978
|
return 0;
|
|
746
979
|
}
|
|
980
|
+
export function receiptFileName(id) {
|
|
981
|
+
return `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, (c) => "~" + c.codePointAt(0).toString(16).padStart(2, "0"))}.json`;
|
|
982
|
+
}
|
|
983
|
+
function writeReceiptFile(name, bytes, say = log) {
|
|
984
|
+
if (existsSync(name)) {
|
|
985
|
+
if (readFileSync(name, "utf8") !== bytes) {
|
|
986
|
+
err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
|
|
987
|
+
return "conflict";
|
|
988
|
+
}
|
|
989
|
+
say(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
|
|
990
|
+
return "identical";
|
|
991
|
+
}
|
|
992
|
+
writeFileSync(name, bytes);
|
|
993
|
+
return "written";
|
|
994
|
+
}
|
|
747
995
|
async function cmdReceipt(id, jsonMode = false) {
|
|
748
996
|
if (!id) {
|
|
749
997
|
err("usage: npx @decidio/sdk receipt <decisionId>");
|
|
@@ -769,19 +1017,13 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
769
1017
|
err(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
|
|
770
1018
|
return 1;
|
|
771
1019
|
}
|
|
772
|
-
const name =
|
|
1020
|
+
const name = receiptFileName(id);
|
|
773
1021
|
const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
(jsonMode ? err : log)(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
|
|
780
|
-
}
|
|
781
|
-
else {
|
|
782
|
-
writeFileSync(name, nextBytes);
|
|
1022
|
+
const wrote = writeReceiptFile(name, nextBytes, jsonMode ? err : log);
|
|
1023
|
+
if (wrote === "conflict")
|
|
1024
|
+
return 1;
|
|
1025
|
+
if (wrote === "written")
|
|
783
1026
|
(jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
|
|
784
|
-
}
|
|
785
1027
|
const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
|
|
786
1028
|
const conf = detail.body?.record?.confirmation;
|
|
787
1029
|
const issuer = r.body?.issuer;
|
|
@@ -873,16 +1115,35 @@ function cmdVerify(argsIn) {
|
|
|
873
1115
|
log(l);
|
|
874
1116
|
return ok ? 0 : 1;
|
|
875
1117
|
}
|
|
1118
|
+
function valuedOpt(args, flag) {
|
|
1119
|
+
const i = args.indexOf(flag);
|
|
1120
|
+
if (i >= 0)
|
|
1121
|
+
return args[i + 1];
|
|
1122
|
+
const eq = args.find((x) => x.startsWith(flag + "="));
|
|
1123
|
+
return eq ? eq.slice(flag.length + 1) : undefined;
|
|
1124
|
+
}
|
|
876
1125
|
async function cmdQuickstart(opts = {}) {
|
|
877
1126
|
const t0 = Date.now();
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
log("
|
|
884
|
-
log(
|
|
885
|
-
|
|
1127
|
+
EXPLAIN = !!opts.explain;
|
|
1128
|
+
if (opts.phone !== undefined && opts.phone !== "app" && opts.phone !== "web") {
|
|
1129
|
+
err(`✘ --phone takes app (default: the QR opens the installed app when there is one) or web (the QR opens the phone web rendering, always).`);
|
|
1130
|
+
return 2;
|
|
1131
|
+
}
|
|
1132
|
+
log(bold("Decidio quickstart") + " — gate an action, route it to a human, execute on approval, hold the proof.");
|
|
1133
|
+
log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : dim(" (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)")}`);
|
|
1134
|
+
hostAnnounced = true;
|
|
1135
|
+
if (EXPLAIN) {
|
|
1136
|
+
log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
|
|
1137
|
+
log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
|
|
1138
|
+
log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
|
|
1139
|
+
log(" as decidio-receipt-<id>.json here (shareable: action, amount, policy, the human's reason - no secrets; it stays)");
|
|
1140
|
+
log(" · create one synthetic decision and its sealed record (records are permanent by design) · wait up to");
|
|
1141
|
+
log(" 5 minutes for your y (approve) or n (reject) at the human step. Cleanup offer at the end.");
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
log(dim(" Creates here: a synthetic agent + 7-day token (.env), .decidio-token-journal (revocation ids, no secrets), .gitignore entries incl."));
|
|
1145
|
+
log(dim(" .decidio-pending/ (the durable store), the receipt as decidio-receipt-<id>.json, one sealed record (permanent). --explain for the long form."));
|
|
1146
|
+
}
|
|
886
1147
|
log("");
|
|
887
1148
|
const session = await acquireSession("The quickstart");
|
|
888
1149
|
if (!session)
|
|
@@ -892,7 +1153,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
892
1153
|
return 1;
|
|
893
1154
|
ensureGitignore();
|
|
894
1155
|
let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
|
|
895
|
-
|
|
1156
|
+
if (EXPLAIN)
|
|
1157
|
+
log(` Registering agent "${agentId}"…`);
|
|
896
1158
|
const { did, privateKeyBase64 } = generateKeypair();
|
|
897
1159
|
let reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
|
|
898
1160
|
for (let attempt = 0; reg.status === 409 && attempt < 3; attempt++) {
|
|
@@ -911,7 +1173,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
911
1173
|
else {
|
|
912
1174
|
err(` Check the target and your sign-in, then re-run: npx @decidio/sdk doctor`);
|
|
913
1175
|
}
|
|
914
|
-
err(`
|
|
1176
|
+
err(` No credential was written to this directory (the only write so far is the .gitignore entries above, which are safe to keep).`);
|
|
915
1177
|
return 1;
|
|
916
1178
|
}
|
|
917
1179
|
let prevTourFile = null;
|
|
@@ -958,8 +1220,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
958
1220
|
if (prevId && prevId !== agentId) {
|
|
959
1221
|
if (prevJti) {
|
|
960
1222
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
|
|
961
|
-
if (rv.status >= 200 && rv.status < 300)
|
|
1223
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
1224
|
+
journalRetire(prevJti, "revoked");
|
|
962
1225
|
log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
|
|
1226
|
+
}
|
|
963
1227
|
else {
|
|
964
1228
|
carried.add(prevJti);
|
|
965
1229
|
log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — it is still LIVE. Its id is kept in ${envFile} as DECIDIO_ORPHANED_TOKEN_JTI, and \`quickstart cleanup\` will retry it; or revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
|
|
@@ -986,7 +1250,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
986
1250
|
err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
|
|
987
1251
|
return 1;
|
|
988
1252
|
}
|
|
989
|
-
log(` ✔
|
|
1253
|
+
log(` ✔ .env already holds your agent — this tour uses ${envFile}`);
|
|
990
1254
|
}
|
|
991
1255
|
else {
|
|
992
1256
|
try {
|
|
@@ -1008,14 +1272,20 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1008
1272
|
await quickstartCleanup(session, envFile).catch(() => { });
|
|
1009
1273
|
return code;
|
|
1010
1274
|
};
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1275
|
+
block("①", "Register a synthetic agent", [
|
|
1276
|
+
`${bold("agent")} ${agentId}`,
|
|
1277
|
+
`${bold("key")} generated locally · only the public half was registered`,
|
|
1278
|
+
`${bold("token")} 7-day, floor-limited${mint.body.expiresAt ? ` · expires ${String(mint.body.expiresAt).slice(0, 10)}` : ""} · written to ${envFile}`,
|
|
1279
|
+
]);
|
|
1280
|
+
if (EXPLAIN) {
|
|
1281
|
+
log(` The tour runs exactly this, the BLOCKING primitive (your real agent uses guard.protect instead:`);
|
|
1282
|
+
log(` durable by default, resumed by a worker — the README shows both):`);
|
|
1283
|
+
log("");
|
|
1284
|
+
log(` const payInvoice = withApproval(payInvoiceRaw,`);
|
|
1285
|
+
log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }),`);
|
|
1286
|
+
log(` { ...config, pollTimeoutMs: 300_000 }); // waits up to 5 minutes for the human`);
|
|
1287
|
+
log("");
|
|
1288
|
+
}
|
|
1019
1289
|
let decisionId = "";
|
|
1020
1290
|
const cfg = {
|
|
1021
1291
|
apiUrl: API, agentId, apiToken: mint.body.token, agentKey: privateKeyBase64,
|
|
@@ -1044,14 +1314,84 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1044
1314
|
err("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
|
|
1045
1315
|
return failTour(1);
|
|
1046
1316
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1317
|
+
block("②", "Gate a demo action", [
|
|
1318
|
+
`${bold("call")} withApproval(payInvoiceRaw, (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), { pollTimeoutMs: 300_000 }) ${dim("// blocking here; your agent's guard.protect is durable")}`,
|
|
1319
|
+
`${bold("routed")} $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — nothing executes until someone decides ${dim("(routed, not a BLOCK: a human can still say yes; a block no one can override)")}`,
|
|
1320
|
+
]);
|
|
1321
|
+
if (EXPLAIN) {
|
|
1322
|
+
log(` This agent has no auto-approve rule, so the policy ROUTES it to a person (routing-by-default). That is`);
|
|
1323
|
+
log(` distinct from a BLOCK, which no human can override: a block is a named rule, or the universal backstop`);
|
|
1324
|
+
log(` when no active policy governs agents at all.`);
|
|
1325
|
+
log("");
|
|
1326
|
+
}
|
|
1052
1327
|
const interactive = process.stdin.isTTY;
|
|
1328
|
+
let verdict = "approve";
|
|
1329
|
+
let decidedOutside = null;
|
|
1330
|
+
let denialVerified = false;
|
|
1053
1331
|
if (interactive) {
|
|
1054
|
-
await
|
|
1332
|
+
const handoff = await api("/demo/session-link", { method: "POST", body: JSON.stringify({ decisionId, ...(opts.phone === "web" ? { phone: "web" } : {}) }) }, session, true);
|
|
1333
|
+
const doorLink = handoff.status >= 200 && handoff.status < 300 && typeof handoff.body?.link === "string" ? String(handoff.body.link) : null;
|
|
1334
|
+
const qr = doorLink && typeof handoff.body?.qr === "string" ? String(handoff.body.qr) : null;
|
|
1335
|
+
const plainLink = `${APP_URL}/queue?focus=${encodeURIComponent(decisionId)}`;
|
|
1336
|
+
const rows = [
|
|
1337
|
+
`${bold("here")} Type y to approve or n to reject, then Enter ${dim("(waits up to 5 minutes)")}`,
|
|
1338
|
+
`${bold("web app")} ${link(doorLink ?? plainLink)} ${dim(doorLink ? "(one-time sign-in · 5 minutes)" : "(sign in there if it asks)")}`,
|
|
1339
|
+
];
|
|
1340
|
+
if (qr) {
|
|
1341
|
+
rows.push(`${bold("your phone")} scan — the same one-time sign-in${opts.phone === "web" ? ", in the phone browser (--phone web)" : ": the Decidio app if it is installed, the browser if not"}`);
|
|
1342
|
+
for (const l of qr.split("\n"))
|
|
1343
|
+
if (l.trim().length)
|
|
1344
|
+
rows.push(" " + l);
|
|
1345
|
+
}
|
|
1346
|
+
else if (doorLink)
|
|
1347
|
+
rows.push(`${bold("your phone")} ${dim("the link above works on a phone too (no QR came back this time)")}`);
|
|
1348
|
+
rows.push(dim("whichever answers first wins — the terminal follows the web app or your phone."));
|
|
1349
|
+
block("③", "Your call", rows, "choice");
|
|
1350
|
+
let settledState = null;
|
|
1351
|
+
const settledP = running.then(() => { settledState = "approved"; }, (e) => { settledState = e instanceof DecidioRejectedError ? "rejected" : "other"; });
|
|
1352
|
+
verdict = "again";
|
|
1353
|
+
const promptP = (async () => {
|
|
1354
|
+
for (let attempt = 0; attempt < 3 && verdict === "again" && !settledState; attempt++) {
|
|
1355
|
+
const answer = await ask(attempt === 0 ? ` › ` : ` Please answer y (approve) or n (reject): `);
|
|
1356
|
+
if (settledState)
|
|
1357
|
+
break;
|
|
1358
|
+
verdict = approvalAnswer(answer);
|
|
1359
|
+
if (process.stdin.readableEnded || process.stdin.destroyed) {
|
|
1360
|
+
verdict = "again";
|
|
1361
|
+
break;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
})();
|
|
1365
|
+
await Promise.race([promptP, settledP]);
|
|
1366
|
+
if (settledState) {
|
|
1367
|
+
currentRl?.close();
|
|
1368
|
+
stdinReadAbandoned = true;
|
|
1369
|
+
await promptP;
|
|
1370
|
+
log("");
|
|
1371
|
+
}
|
|
1372
|
+
if (settledState === "approved" || settledState === "rejected") {
|
|
1373
|
+
decidedOutside = settledState;
|
|
1374
|
+
verdict = settledState === "approved" ? "approve" : "reject";
|
|
1375
|
+
}
|
|
1376
|
+
else if (settledState === "other")
|
|
1377
|
+
verdict = "again";
|
|
1378
|
+
if (verdict === "again" && !(earlyError instanceof DecidioTimeoutError)) {
|
|
1379
|
+
log(`③ No decision was made (no y or n answer), so the tour stops here rather than guess.`);
|
|
1380
|
+
log(` The request is still ROUTED and waiting in your queue - approve or reject it there, or re-run the tour.`);
|
|
1381
|
+
log(` NOTE: this tour's credentials are still live in ${envFile} (a 7-day token + the agent's key). Once you`);
|
|
1382
|
+
log(` have decided - or if you abandon the tour - remove them: npx @decidio/sdk quickstart cleanup`);
|
|
1383
|
+
log("");
|
|
1384
|
+
sessionReminder();
|
|
1385
|
+
process.exit(3);
|
|
1386
|
+
}
|
|
1387
|
+
if (earlyError instanceof DecidioTimeoutError) {
|
|
1388
|
+
log(`③ the tour's blocking call gave up after 5 minutes while waiting for your answer, so your answer is NOT posted:`);
|
|
1389
|
+
log(` the request is still ROUTED in your queue - decide it there, or re-run the tour and answer within 5 minutes.`);
|
|
1390
|
+
log(` NOTE: this tour's credentials are still live in ${envFile} - remove them: npx @decidio/sdk quickstart cleanup`);
|
|
1391
|
+
log("");
|
|
1392
|
+
sessionReminder();
|
|
1393
|
+
process.exit(3);
|
|
1394
|
+
}
|
|
1055
1395
|
}
|
|
1056
1396
|
else if (opts.autoApproveDemo) {
|
|
1057
1397
|
log("③ --auto-approve-demo: approving with your session credential. This is AUTOMATED authority");
|
|
@@ -1076,69 +1416,167 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1076
1416
|
log(` NOTE: this tour's credentials are still live in ${envFile} (a 7-day token + the agent's`);
|
|
1077
1417
|
log(` key), because the decision above is still waiting for you. Once you have approved it —`);
|
|
1078
1418
|
log(` or if you abandon the tour — remove them: npx @decidio/sdk quickstart cleanup`);
|
|
1419
|
+
log("");
|
|
1420
|
+
sessionReminder();
|
|
1079
1421
|
process.exit(3);
|
|
1080
1422
|
}
|
|
1081
|
-
const
|
|
1082
|
-
if (
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1423
|
+
const rejected = verdict === "reject";
|
|
1424
|
+
if (rejected) {
|
|
1425
|
+
const rj = decidedOutside ? { status: 200, body: { alreadyResolved: true, sealed: true } } : await api(`/api/decisions/${encodeURIComponent(decisionId)}/reject`, { method: "POST", body: JSON.stringify({ reason: "quickstart: rejected by the approver" }) }, session);
|
|
1426
|
+
if (!(rj.status >= 200 && rj.status < 300)) {
|
|
1427
|
+
err(`✘ reject failed (${rj.status}): ${JSON.stringify(rj.body)}`);
|
|
1428
|
+
return failTour(1);
|
|
1429
|
+
}
|
|
1430
|
+
if (decidedOutside)
|
|
1431
|
+
log(` ✔ rejected in the web app (or on your phone) — this terminal saw it and followed`);
|
|
1432
|
+
const decidedInApp = rj.body?.alreadyResolved === true;
|
|
1433
|
+
if (!decidedOutside)
|
|
1434
|
+
log(decidedInApp ? ` • this decision was already decided from your app — the tour follows that decision` : ` ✔ rejected`);
|
|
1435
|
+
let leaked;
|
|
1436
|
+
let refused;
|
|
1437
|
+
try {
|
|
1438
|
+
leaked = await running;
|
|
1439
|
+
}
|
|
1440
|
+
catch (e) {
|
|
1441
|
+
refused = e;
|
|
1442
|
+
}
|
|
1443
|
+
if (refused === undefined && !decidedInApp) {
|
|
1444
|
+
err(`✘ the gated function RAN after the rejection (it returned ${JSON.stringify(leaked)}) — this is a defect, not a tour. Please report it: decidioai.com/#early-access`);
|
|
1445
|
+
return failTour(1);
|
|
1446
|
+
}
|
|
1447
|
+
const ranOnAppApproval = refused === undefined;
|
|
1448
|
+
if (ranOnAppApproval) {
|
|
1449
|
+
log(` ✔ your app APPROVED it first, so that decision stood and your function ran: ${JSON.stringify(leaked)}. Re-run the tour to watch a rejection.`);
|
|
1450
|
+
}
|
|
1451
|
+
else if (refused instanceof DecidioRejectedError) {
|
|
1452
|
+
log(` ✔ your function did NOT run — the wrapper refused: ${refused.message}${EXPLAIN ? "" : dim(" (a human said no, and nothing happened)")}`);
|
|
1453
|
+
if (EXPLAIN)
|
|
1454
|
+
log(` That is the product: a human said no, and nothing happened.`);
|
|
1455
|
+
if (EXPLAIN)
|
|
1456
|
+
log(" Downloading the sealed record of the DENIAL and verifying it OFFLINE…");
|
|
1457
|
+
const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
|
|
1458
|
+
if (rec.status >= 200 && rec.status < 300 && rec.body?.credential) {
|
|
1459
|
+
const fname = receiptFileName(decisionId);
|
|
1460
|
+
const wrote = writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
1461
|
+
if (wrote === "conflict")
|
|
1462
|
+
return failTour(1);
|
|
1463
|
+
const issuer = rec.body?.issuer;
|
|
1464
|
+
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1465
|
+
const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", PINNED to your workspace issuer" : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
|
|
1466
|
+
if (!v.valid || !issuer) {
|
|
1467
|
+
log(` ${kindLine}`);
|
|
1468
|
+
err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no issuer DID from the server" : v.reason ?? "no reason given"}) — the tour did not complete.`);
|
|
1469
|
+
return failTour(1);
|
|
1470
|
+
}
|
|
1471
|
+
block("④", "The record", [
|
|
1472
|
+
`✔ VALID — ${fname} ${dim("(signature + content binding, offline, PINNED to your workspace issuer)")}`,
|
|
1473
|
+
`${bold("record")} record kind: ${recordKindOf(rec.body.credential) ?? "?"} ${dim("— the denial is sealed too")}`,
|
|
1474
|
+
`${bold("issuer")} ${issuer} ${dim("· your trust anchor: verify --issuer <did> <receipt.json>")}`,
|
|
1475
|
+
], "bad");
|
|
1476
|
+
denialVerified = true;
|
|
1477
|
+
}
|
|
1478
|
+
else {
|
|
1479
|
+
log(` • no sealed record was issued for this rejection (${rec.status}) — the denial is on the decision in your queue.`);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
else {
|
|
1483
|
+
err(`✘ the gated call ended with an error rather than a clean refusal: ${refused?.message ?? refused} — nothing ran, but the tour did not complete.`);
|
|
1484
|
+
return failTour(1);
|
|
1485
|
+
}
|
|
1486
|
+
log("");
|
|
1487
|
+
const secsR = Math.round((Date.now() - t0) / 1000);
|
|
1488
|
+
if (ranOnAppApproval)
|
|
1489
|
+
log(`Done in ${secsR}s: gated → routed to a human → decided in your app (approved) → your function ran once.`);
|
|
1490
|
+
else {
|
|
1491
|
+
block("✔", `Done in ${secsR}s`, [
|
|
1492
|
+
`${bold("gated")} payInvoice $86,000 — ROUTED to a human as ${decisionId}`,
|
|
1493
|
+
`${bold("decided")} REJECTED ${decidedOutside ? "in the web app (or on your phone)" : "by you, here"}`,
|
|
1494
|
+
`${bold("executed")} nothing — your function never ran`,
|
|
1495
|
+
`${bold("record")} ${denialVerified ? "the DENIAL is sealed too — VALID, verified offline (④ above)" : "none was issued for this rejection (④ above)"}`,
|
|
1496
|
+
dim("gated → routed to a human → REJECTED → your function never ran"),
|
|
1497
|
+
], "bad");
|
|
1498
|
+
log(`Re-run the tour and answer y to watch the approval half, with its offline-verifiable receipt.`);
|
|
1499
|
+
}
|
|
1500
|
+
log("");
|
|
1501
|
+
sessionReminder();
|
|
1502
|
+
log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
|
|
1113
1503
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1504
|
+
else {
|
|
1505
|
+
const ap = decidedOutside ? { status: 200, body: { alreadyResolved: true } } : await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
|
|
1506
|
+
if (!(ap.status >= 200 && ap.status < 300)) {
|
|
1507
|
+
err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
|
|
1508
|
+
return failTour(1);
|
|
1509
|
+
}
|
|
1510
|
+
log(decidedOutside ? ` ✔ approved in the web app (or on your phone) — this terminal saw it and followed` : ` ✔ approved${ap.body?.alreadyResolved ? " (already decided from your app — the tour follows that decision)" : ""}`);
|
|
1511
|
+
let done;
|
|
1512
|
+
try {
|
|
1513
|
+
done = await running;
|
|
1514
|
+
}
|
|
1515
|
+
catch (e) {
|
|
1516
|
+
if (e instanceof DecidioRejectedError) {
|
|
1517
|
+
err(`✘ it was REJECTED from your app before this tour's approval landed — your function did NOT run (that is the product). Re-run the tour to watch the approval half.`);
|
|
1518
|
+
return failTour(1);
|
|
1519
|
+
}
|
|
1520
|
+
err(`✘ the gated call did not complete: ${e?.message ?? e}`);
|
|
1521
|
+
return failTour(1);
|
|
1522
|
+
}
|
|
1523
|
+
log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}${done.confirmation?.evidenceTier ? ` · confirmation.evidenceTier = ${done.confirmation.evidenceTier}` : ""}`);
|
|
1524
|
+
if (EXPLAIN && done.confirmation?.evidenceTier)
|
|
1525
|
+
log(` (the call itself told you what was recorded — the wrapper's signed report)`);
|
|
1526
|
+
if (EXPLAIN)
|
|
1527
|
+
log(" Downloading the sealed Authority Receipt and verifying it OFFLINE…");
|
|
1528
|
+
const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
|
|
1529
|
+
if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
|
|
1530
|
+
err(`✘ receipt fetch failed (${rec.status})`);
|
|
1531
|
+
return failTour(1);
|
|
1532
|
+
}
|
|
1533
|
+
const fname = receiptFileName(decisionId);
|
|
1534
|
+
if (writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n") === "conflict")
|
|
1535
|
+
return failTour(1);
|
|
1536
|
+
const issuer = rec.body?.issuer;
|
|
1537
|
+
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1538
|
+
const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", PINNED to your workspace issuer" : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
|
|
1539
|
+
if (!v.valid || !issuer)
|
|
1540
|
+
log(` ${kindLine}`);
|
|
1541
|
+
if (!v.valid) {
|
|
1542
|
+
err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
|
|
1543
|
+
return failTour(1);
|
|
1544
|
+
}
|
|
1545
|
+
if (!issuer) {
|
|
1546
|
+
err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
|
|
1547
|
+
err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
1548
|
+
err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
|
|
1549
|
+
err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
|
|
1550
|
+
err(` ✘ the tour does not count an UNPINNED check as success.`);
|
|
1551
|
+
return failTour(1);
|
|
1552
|
+
}
|
|
1553
|
+
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
1554
|
+
const tier = detail.body?.record?.confirmation?.status;
|
|
1555
|
+
block("④", "The receipt", [
|
|
1556
|
+
`✔ VALID — ${fname} ${dim("(signature + content binding, offline, PINNED to your workspace issuer)")}`,
|
|
1557
|
+
`${bold("record")} record kind: ${recordKindOf(rec.body.credential) ?? "?"}${tier ? ` · evidence: ${tier}` : ""} ${dim("— the receipt seals the DECISION; execution evidence lives on the record")}`,
|
|
1558
|
+
`${bold("issuer")} ${issuer} ${dim("· your trust anchor: verify --issuer <did> <receipt.json>")}`,
|
|
1559
|
+
], "good");
|
|
1560
|
+
const secs = Math.round((Date.now() - t0) / 1000);
|
|
1561
|
+
block("✔", `Done in ${secs}s`, [
|
|
1562
|
+
`${bold("gated")} payInvoice $86,000 — ROUTED to a human as ${decisionId}`,
|
|
1563
|
+
`${bold("decided")} ${decidedOutside ? "approved in the web app (or on your phone)" : opts.autoApproveDemo ? "approved by --auto-approve-demo (automated, and labeled so on the record)" : "approved by you, here"}`,
|
|
1564
|
+
`${bold("executed")} your function ran once, AFTER approval`,
|
|
1565
|
+
`${bold("receipt")} VALID — verified offline, pinned to your workspace issuer (④ above)`,
|
|
1566
|
+
dim("gated → routed to a human → approved → executed once → a receipt you can verify offline"),
|
|
1567
|
+
], "good");
|
|
1568
|
+
log("");
|
|
1569
|
+
log(bold("Take it to your real agent — the .env here already works:"));
|
|
1570
|
+
log(` import { guard } from "@decidio/sdk";`);
|
|
1571
|
+
log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));${dim(" // durable by default")}`);
|
|
1572
|
+
log("");
|
|
1573
|
+
if (EXPLAIN) {
|
|
1574
|
+
log(` The README's seven-step flow is this same blocking flow in your own code; if you keep this agent, its .env`);
|
|
1575
|
+
log(` already works here, so skip that flow's init step. Production is DURABLE mode (default) + one worker.`);
|
|
1576
|
+
}
|
|
1577
|
+
sessionReminder();
|
|
1578
|
+
log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
|
|
1121
1579
|
}
|
|
1122
|
-
if (issuer)
|
|
1123
|
-
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
1124
|
-
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
1125
|
-
const tier = detail.body?.record?.confirmation?.status;
|
|
1126
|
-
if (tier)
|
|
1127
|
-
log(` ✔ execution evidence on the record: ${tier} — the wrapper's signed report. (The receipt seals the DECISION; execution evidence accrues on the record — see the README.)`);
|
|
1128
|
-
log("");
|
|
1129
|
-
const secs = Math.round((Date.now() - t0) / 1000);
|
|
1130
|
-
log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
|
|
1131
|
-
log("");
|
|
1132
|
-
log("Take it to your real agent — the .env here already works:");
|
|
1133
|
-
log(` import { guard } from "@decidio/sdk";`);
|
|
1134
|
-
log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
|
|
1135
|
-
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
1136
|
-
log("");
|
|
1137
|
-
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
|
|
1138
|
-
log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
|
|
1139
|
-
log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
|
|
1140
|
-
log(`already works here, so skip that flow's init step.`);
|
|
1141
|
-
log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
|
|
1142
1580
|
let doCleanup = false;
|
|
1143
1581
|
if (interactive) {
|
|
1144
1582
|
const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
|
|
@@ -1151,7 +1589,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1151
1589
|
log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
|
|
1152
1590
|
}
|
|
1153
1591
|
const cleanupCode = doCleanup ? await quickstartCleanup(session, envFile) : 0;
|
|
1154
|
-
return
|
|
1592
|
+
return cleanupCode || 0;
|
|
1155
1593
|
}
|
|
1156
1594
|
async function quickstartCleanup(session, envFile) {
|
|
1157
1595
|
const env = (() => {
|
|
@@ -1172,13 +1610,25 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1172
1610
|
let journalRevoked = 0;
|
|
1173
1611
|
const journalStuck = [];
|
|
1174
1612
|
let outstanding = [];
|
|
1613
|
+
let heldElsewhere = [];
|
|
1614
|
+
let unheld = [];
|
|
1615
|
+
let journalReconciled = 0;
|
|
1175
1616
|
try {
|
|
1176
|
-
|
|
1617
|
+
const ownIds = new Set([env.DECIDIO_TOKEN_JTI, ...(env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim())].filter(Boolean));
|
|
1618
|
+
outstanding = journalOwed().filter((j) => !ownIds.has(j.jti));
|
|
1619
|
+
unheld = journalUnheld();
|
|
1620
|
+
const owedIds = new Set(outstanding.map((j) => j.jti));
|
|
1621
|
+
const unheldIds = new Set(unheld.map((j) => j.jti));
|
|
1622
|
+
heldElsewhere = journalOutstanding().filter((j) => !owedIds.has(j.jti) && !unheldIds.has(j.jti) && !ownIds.has(j.jti));
|
|
1177
1623
|
}
|
|
1178
1624
|
catch (e) {
|
|
1179
1625
|
err(`✘ the token recovery journal cannot be trusted (${e?.message ?? e}). It may hold the id of a live token, so this cleanup cannot claim nothing is owed. Every byte is left as found: inspect ${JOURNAL_PATH}, revoke anything it names (npx @decidio/sdk tokens list --mine), then repair or move it aside and re-run.`);
|
|
1180
1626
|
return 1;
|
|
1181
1627
|
}
|
|
1628
|
+
for (const j of heldElsewhere)
|
|
1629
|
+
log(` • journalled token ${j.jti} (${j.label ?? "token"}) is live and still held by a credential file here — not a tour token, left alone (revoke it yourself: npx @decidio/sdk tokens revoke ${j.jti})`);
|
|
1630
|
+
for (const j of unheld)
|
|
1631
|
+
err(` ⚠ journalled token ${j.jti} (${j.label ?? "token"}) is live: it was written to a credential file that is no longer here — not a tour token, so this cleanup will not revoke it. If it is yours and lost, revoke it: npx @decidio/sdk tokens revoke ${j.jti}`);
|
|
1182
1632
|
for (const j of outstanding) {
|
|
1183
1633
|
if (j.apiUrl && j.apiUrl !== API) {
|
|
1184
1634
|
journalStuck.push(j.jti);
|
|
@@ -1188,11 +1638,14 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1188
1638
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
|
|
1189
1639
|
if (rv.status >= 200 && rv.status < 300) {
|
|
1190
1640
|
journalRetire(j.jti, "revoked");
|
|
1191
|
-
|
|
1192
|
-
|
|
1641
|
+
if (rv.body?.changed === false) {
|
|
1642
|
+
journalReconciled++;
|
|
1193
1643
|
log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
|
|
1194
|
-
|
|
1195
|
-
|
|
1644
|
+
}
|
|
1645
|
+
else {
|
|
1646
|
+
journalRevoked++;
|
|
1647
|
+
log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — from the journal: ${j.persisted ? "its credential file is gone" : "its credential file never landed"}, so the journal was the handle`);
|
|
1648
|
+
}
|
|
1196
1649
|
}
|
|
1197
1650
|
else {
|
|
1198
1651
|
journalStuck.push(j.jti);
|
|
@@ -1209,8 +1662,10 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1209
1662
|
const stillOrphaned = [];
|
|
1210
1663
|
for (const jti of orphans) {
|
|
1211
1664
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
|
|
1212
|
-
if (rv.status >= 200 && rv.status < 300)
|
|
1665
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
1666
|
+
journalRetire(jti, "revoked");
|
|
1213
1667
|
log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
|
|
1668
|
+
}
|
|
1214
1669
|
else {
|
|
1215
1670
|
stillOrphaned.push(jti);
|
|
1216
1671
|
err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
|
|
@@ -1224,6 +1679,8 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1224
1679
|
}
|
|
1225
1680
|
if (env.DECIDIO_TOKEN_JTI) {
|
|
1226
1681
|
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
|
|
1682
|
+
if (rev.status >= 200 && rev.status < 300)
|
|
1683
|
+
journalRetire(env.DECIDIO_TOKEN_JTI, "revoked");
|
|
1227
1684
|
if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
|
|
1228
1685
|
log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
|
|
1229
1686
|
else if (rev.status >= 200 && rev.status < 300)
|
|
@@ -1238,6 +1695,12 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1238
1695
|
else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
|
|
1239
1696
|
log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
|
|
1240
1697
|
}
|
|
1698
|
+
else if (!existsSync(envFile)) {
|
|
1699
|
+
if (unheld.length)
|
|
1700
|
+
err(` • no credential file here (${envFile} is already gone); the journal names ${unheld.length} live token(s) this cleanup will not revoke (above).`);
|
|
1701
|
+
else
|
|
1702
|
+
log(` • no credential file here (${envFile} is already gone) and the token recovery journal owes nothing further${journalReconciled ? ` (${journalReconciled} already-revoked row(s) reconciled)` : ""}.`);
|
|
1703
|
+
}
|
|
1241
1704
|
else {
|
|
1242
1705
|
err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
|
|
1243
1706
|
err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
|
|
@@ -1281,12 +1744,18 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1281
1744
|
}
|
|
1282
1745
|
log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
|
|
1283
1746
|
log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
|
|
1747
|
+
if (existsSync(JOURNAL_PATH))
|
|
1748
|
+
log(` • Also kept: ${JOURNAL_PATH} — the ids and timestamps of what this directory minted and revoked (no secrets). Gitignored; owner-only where the OS supports it.`);
|
|
1284
1749
|
if (existsSync(".decidio-pending")) {
|
|
1285
1750
|
log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
|
|
1286
1751
|
log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
|
|
1287
1752
|
log(` pending and you do not want the history, remove it:`);
|
|
1288
1753
|
log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
|
|
1289
1754
|
}
|
|
1755
|
+
if (endSession())
|
|
1756
|
+
log(` ✔ signed out — the stored sign-in is ended (npx @decidio/sdk login to sign in again).`);
|
|
1757
|
+
else if (process.env.DECIDIO_SESSION_TOKEN)
|
|
1758
|
+
log(` • DECIDIO_SESSION_TOKEN is still exported in this shell — unset it when you are done.`);
|
|
1290
1759
|
return 0;
|
|
1291
1760
|
}
|
|
1292
1761
|
async function cmdTokens(sub, jti, mineOnly = false) {
|
|
@@ -1358,6 +1827,13 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
1358
1827
|
return 0;
|
|
1359
1828
|
}
|
|
1360
1829
|
function cmdDev(target, port) {
|
|
1830
|
+
try {
|
|
1831
|
+
assertSafeTransport(target, true);
|
|
1832
|
+
}
|
|
1833
|
+
catch (e) {
|
|
1834
|
+
err(`✘ dev relay refused: ${e?.message ?? e}`);
|
|
1835
|
+
return 2;
|
|
1836
|
+
}
|
|
1361
1837
|
log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
|
|
1362
1838
|
createServer((req, res) => {
|
|
1363
1839
|
if (req.method !== "POST") {
|
|
@@ -1379,11 +1855,14 @@ function cmdDev(target, port) {
|
|
|
1379
1855
|
res.end(JSON.stringify({ error: e?.message }));
|
|
1380
1856
|
}
|
|
1381
1857
|
});
|
|
1382
|
-
}).listen(port);
|
|
1858
|
+
}).listen(port, "127.0.0.1");
|
|
1383
1859
|
return 0;
|
|
1384
1860
|
}
|
|
1385
1861
|
const USAGE = `decidio — the Decidio SDK command line
|
|
1386
1862
|
|
|
1863
|
+
The wrapper IS the SDK: guard.protect(fn, describe) ships in this package (import { guard } from "@decidio/sdk").
|
|
1864
|
+
This command line is the tour, the sign-in, and the token admin around it.
|
|
1865
|
+
|
|
1387
1866
|
New here? See the whole thing work in about two minutes:
|
|
1388
1867
|
|
|
1389
1868
|
npx @decidio/sdk quickstart
|
|
@@ -1393,23 +1872,36 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
1393
1872
|
hosted record — the receipt seals who authorized what, not what ran afterward). Guided and
|
|
1394
1873
|
explicit — this CLI never acts, signs you in, or writes files unless you ask it to.
|
|
1395
1874
|
|
|
1875
|
+
THE TOUR
|
|
1396
1876
|
npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
|
|
1397
|
-
(interactive:
|
|
1877
|
+
(interactive: y approves, n rejects - Enter is not an answer. Headless it stops
|
|
1398
1878
|
at the routed decision; --auto-approve-demo for CI, labeled
|
|
1399
|
-
automated; ends with a keep-or-cleanup choice
|
|
1879
|
+
automated; ends with a keep-or-cleanup choice; --explain prints
|
|
1880
|
+
the long narration of each step; --phone web makes the QR open
|
|
1881
|
+
the phone web rendering even when the Decidio app is installed)
|
|
1400
1882
|
npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
|
|
1883
|
+
(and end the stored sign-in)
|
|
1401
1884
|
npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
|
|
1402
1885
|
(add --keep-agent to KEEP the tour agent + token)
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1886
|
+
|
|
1887
|
+
SIGN IN (once)
|
|
1888
|
+
npx @decidio/sdk login [email] sign in ONCE: the CLI emails you a link, you paste it; the sign-in
|
|
1889
|
+
lasts 30 days, or until this terminal closes, logout, or cleanup
|
|
1890
|
+
npx @decidio/sdk logout end the stored sign-in
|
|
1891
|
+
|
|
1892
|
+
YOUR AGENT AND ITS TOKENS
|
|
1407
1893
|
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
1408
1894
|
--unique append a random suffix (collision-proof samples)
|
|
1409
1895
|
--force replace a different agent already in this .env
|
|
1410
1896
|
(its .env is backed up first)
|
|
1411
1897
|
--expires-days N agent-token lifetime (default 90)
|
|
1412
1898
|
npx @decidio/sdk doctor check config, connectivity, and what your token can do
|
|
1899
|
+
npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
|
|
1900
|
+
jti — token values are never re-shown; the list is
|
|
1901
|
+
workspace-wide, --mine narrows to this directory's agent
|
|
1902
|
+
npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
|
|
1903
|
+
|
|
1904
|
+
APPROVALS AND RECEIPTS
|
|
1413
1905
|
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
1414
1906
|
npx @decidio/sdk approvals approve <id> [reason]
|
|
1415
1907
|
npx @decidio/sdk approvals reject <id> [reason]
|
|
@@ -1419,21 +1911,110 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
1419
1911
|
If it starts with a dash, put it after -- or use --reason:
|
|
1420
1912
|
... approve <id> -- "-50% cut, per the board"
|
|
1421
1913
|
... approve <id> --reason "-50% cut, per the board"
|
|
1422
|
-
npx @decidio/sdk receipt <id>
|
|
1914
|
+
npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt (.json)
|
|
1423
1915
|
npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
|
|
1916
|
+
|
|
1917
|
+
DEVELOPMENT
|
|
1424
1918
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
1425
1919
|
npx @decidio/sdk --version print the CLI version
|
|
1426
1920
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
(
|
|
1921
|
+
NOTES
|
|
1922
|
+
Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
|
|
1923
|
+
DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
|
|
1924
|
+
fully offline — it touches no network and reads no .env.
|
|
1925
|
+
Update check: network commands ask the public npm registry once a day whether a newer version exists. If so,
|
|
1926
|
+
an interactive run asks "Upgrade now? [y/n]" first (y upgrades and re-runs your command; n exits); a headless
|
|
1927
|
+
run prints the notice and continues (DECIDIO_REQUIRE_LATEST=1 makes it exit). DECIDIO_NO_UPDATE_CHECK=1 skips
|
|
1928
|
+
the check. Nothing is sent to Decidio; verify never checks.
|
|
1929
|
+
Help: --help (or -h) after any command prints this page - one page for every command.
|
|
1930
|
+
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
|
|
1931
|
+
3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
|
|
1932
|
+
receipt written — pass --auto-approve-demo for a run that completes unattended).
|
|
1933
|
+
Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
|
|
1934
|
+
pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
|
|
1935
|
+
Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
|
|
1936
|
+
(quickstart, durable resume, workers, adapters).`;
|
|
1937
|
+
const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens"]);
|
|
1938
|
+
function semverNewer(a, b) {
|
|
1939
|
+
const pa = a.split(".").map((x) => parseInt(x, 10)), pb = b.split(".").map((x) => parseInt(x, 10));
|
|
1940
|
+
if (pa.length < 3 || pb.length < 3 || pa.some((x) => Number.isNaN(x)) || pb.some((x) => Number.isNaN(x)))
|
|
1941
|
+
return false;
|
|
1942
|
+
for (let i = 0; i < 3; i++)
|
|
1943
|
+
if (pa[i] !== pb[i])
|
|
1944
|
+
return pa[i] > pb[i];
|
|
1945
|
+
return false;
|
|
1946
|
+
}
|
|
1947
|
+
async function latestPublished() {
|
|
1948
|
+
const cache = joinPath(sessionDir(), "update-check.json");
|
|
1949
|
+
try {
|
|
1950
|
+
const c = JSON.parse(readFileSync(cache, "utf8"));
|
|
1951
|
+
if (c && typeof c.latest === "string" && Date.now() - Number(c.checkedAt) < 24 * 3600 * 1000)
|
|
1952
|
+
return c.latest;
|
|
1953
|
+
}
|
|
1954
|
+
catch { }
|
|
1955
|
+
const reg = (SHELL_ENV.DECIDIO_UPDATE_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
1956
|
+
try {
|
|
1957
|
+
const ctl = new AbortController();
|
|
1958
|
+
const t = setTimeout(() => ctl.abort(), 2000);
|
|
1959
|
+
const r = await fetch(`${reg}/@decidio%2fsdk/latest`, { signal: ctl.signal });
|
|
1960
|
+
clearTimeout(t);
|
|
1961
|
+
if (!r.ok)
|
|
1962
|
+
return null;
|
|
1963
|
+
const body = await r.json();
|
|
1964
|
+
const latest = typeof body?.version === "string" && /^\d+\.\d+\.\d+$/.test(body.version) ? body.version : null;
|
|
1965
|
+
if (latest) {
|
|
1966
|
+
try {
|
|
1967
|
+
mkdirSync(sessionDir(), { recursive: true });
|
|
1968
|
+
writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), latest }));
|
|
1969
|
+
}
|
|
1970
|
+
catch { }
|
|
1971
|
+
}
|
|
1972
|
+
return latest;
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
return null;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
async function updateGate(cmd, argv) {
|
|
1979
|
+
if (!cmd || !GATED_CMDS.has(cmd) || SHELL_ENV.DECIDIO_NO_UPDATE_CHECK || VERSION === "unknown" || VERSION.includes("-"))
|
|
1980
|
+
return null;
|
|
1981
|
+
const latest = await latestPublished();
|
|
1982
|
+
if (!latest || !semverNewer(latest, VERSION))
|
|
1983
|
+
return null;
|
|
1984
|
+
const underNpx = /[\\/]_npx[\\/]/.test(process.argv[1] ?? "");
|
|
1985
|
+
const upgradeCmd = underNpx ? `npx @decidio/sdk@latest ${argv.slice(2).join(" ")}` : "npm i -g @decidio/sdk@latest";
|
|
1986
|
+
const notice = `${latest} is available (you have ${VERSION}).`;
|
|
1987
|
+
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
1988
|
+
err(`⚠ ${notice} Upgrade: ${upgradeCmd}`);
|
|
1989
|
+
if (SHELL_ENV.DECIDIO_REQUIRE_LATEST) {
|
|
1990
|
+
err(`✘ DECIDIO_REQUIRE_LATEST is set — not running a stale version.`);
|
|
1991
|
+
return 1;
|
|
1992
|
+
}
|
|
1993
|
+
return null;
|
|
1994
|
+
}
|
|
1995
|
+
const a = (await ask(`${notice} Upgrade now? [y/n]: `)).toLowerCase();
|
|
1996
|
+
if (a !== "y" && a !== "yes") {
|
|
1997
|
+
err(`✘ not upgraded — run this when ready: ${upgradeCmd}`);
|
|
1998
|
+
return 1;
|
|
1999
|
+
}
|
|
2000
|
+
if (underNpx) {
|
|
2001
|
+
err(` Under npx there is nothing installed to upgrade — re-run as: ${upgradeCmd}`);
|
|
2002
|
+
return 1;
|
|
2003
|
+
}
|
|
2004
|
+
if (SHELL_ENV.DECIDIO_UPDATE_DRY_RUN) {
|
|
2005
|
+
log(` would run: ${upgradeCmd}`);
|
|
2006
|
+
return null;
|
|
2007
|
+
}
|
|
2008
|
+
log(` upgrading: ${upgradeCmd}`);
|
|
2009
|
+
const up = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["i", "-g", "@decidio/sdk@latest"], { stdio: "inherit", shell: process.platform === "win32", env: SHELL_ENV });
|
|
2010
|
+
if (up.status !== 0) {
|
|
2011
|
+
err(`✘ the upgrade did not complete (exit ${up.status ?? "?"}) — run it by hand: ${upgradeCmd}`);
|
|
2012
|
+
return 1;
|
|
2013
|
+
}
|
|
2014
|
+
log(` ✔ upgraded — re-running: decidio ${argv.slice(2).join(" ")}`);
|
|
2015
|
+
const rerun = spawnSync(process.execPath, [process.argv[1], ...argv.slice(2)], { stdio: "inherit", env: SHELL_ENV });
|
|
2016
|
+
return rerun.status ?? 1;
|
|
2017
|
+
}
|
|
1437
2018
|
async function main(argv) {
|
|
1438
2019
|
const [cmd, a, b, ...rest] = argv.slice(2);
|
|
1439
2020
|
if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
|
|
@@ -1448,9 +2029,15 @@ async function main(argv) {
|
|
|
1448
2029
|
log(USAGE);
|
|
1449
2030
|
return 0;
|
|
1450
2031
|
}
|
|
2032
|
+
if (helpFlagAnywhere(argv.slice(2))) {
|
|
2033
|
+
log(USAGE);
|
|
2034
|
+
return 0;
|
|
2035
|
+
}
|
|
1451
2036
|
const argsIn = argv.slice(3);
|
|
1452
2037
|
const KNOWN = {
|
|
1453
|
-
quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
|
|
2038
|
+
quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent"], valued: ["--phone"] },
|
|
2039
|
+
login: { flags: [] },
|
|
2040
|
+
logout: { flags: [] },
|
|
1454
2041
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
1455
2042
|
doctor: { flags: [] },
|
|
1456
2043
|
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
@@ -1491,7 +2078,7 @@ async function main(argv) {
|
|
|
1491
2078
|
return 2;
|
|
1492
2079
|
}
|
|
1493
2080
|
}
|
|
1494
|
-
const MAX_POS = { quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
|
|
2081
|
+
const MAX_POS = { login: 1, logout: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
|
|
1495
2082
|
const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
|
|
1496
2083
|
if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
|
|
1497
2084
|
err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
|
|
@@ -1510,19 +2097,31 @@ async function main(argv) {
|
|
|
1510
2097
|
return 2;
|
|
1511
2098
|
}
|
|
1512
2099
|
}
|
|
2100
|
+
const gated = await updateGate(cmd, argv);
|
|
2101
|
+
if (gated !== null)
|
|
2102
|
+
return gated;
|
|
1513
2103
|
switch (cmd) {
|
|
2104
|
+
case "login": return cmdLogin(argsIn.find((x) => !x.startsWith("-")));
|
|
2105
|
+
case "logout": return cmdLogout();
|
|
1514
2106
|
case "quickstart": {
|
|
1515
2107
|
if (argsIn.includes("cleanup")) {
|
|
1516
2108
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
1517
2109
|
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
|
|
1518
2110
|
: (() => { try {
|
|
1519
|
-
return
|
|
2111
|
+
return (journalOwed().length || journalUnheld().length) ? ".env.quickstart" : undefined;
|
|
1520
2112
|
}
|
|
1521
2113
|
catch {
|
|
1522
2114
|
return ".env.quickstart";
|
|
1523
2115
|
} })();
|
|
1524
2116
|
if (!envFile) {
|
|
1525
|
-
|
|
2117
|
+
let held = [];
|
|
2118
|
+
try {
|
|
2119
|
+
held = journalOutstanding();
|
|
2120
|
+
}
|
|
2121
|
+
catch { }
|
|
2122
|
+
log(held.length
|
|
2123
|
+
? `Nothing to clean up here: no tour credentials. The token recovery journal names ${held.length} live token(s) held by a credential file here (${held.map((j) => j.jti).join(", ")}) — not tour tokens, left alone; doctor lists them.`
|
|
2124
|
+
: "Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal has no live token to act on.");
|
|
1526
2125
|
return 0;
|
|
1527
2126
|
}
|
|
1528
2127
|
const session = await acquireSession("Cleaning the quickstart up");
|
|
@@ -1530,7 +2129,7 @@ async function main(argv) {
|
|
|
1530
2129
|
return 1;
|
|
1531
2130
|
return quickstartCleanup(session, envFile);
|
|
1532
2131
|
}
|
|
1533
|
-
return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent") });
|
|
2132
|
+
return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent"), explain: argsIn.includes("--explain"), phone: valuedOpt(argsIn, "--phone") });
|
|
1534
2133
|
}
|
|
1535
2134
|
case "init": {
|
|
1536
2135
|
const unique = argsIn.includes("--unique");
|
|
@@ -1601,7 +2200,7 @@ async function main(argv) {
|
|
|
1601
2200
|
return 2;
|
|
1602
2201
|
}
|
|
1603
2202
|
if (!/^https?:\/\//.test(target)) {
|
|
1604
|
-
err(`✘ --target needs an http(s) URL to forward the webhook to (got "${target}").`);
|
|
2203
|
+
err(`✘ --target needs an http(s) URL to forward the webhook to (got "${redactUrl(target)}").`);
|
|
1605
2204
|
return 2;
|
|
1606
2205
|
}
|
|
1607
2206
|
return cmdDev(target, Number(portRaw));
|
|
@@ -1614,5 +2213,26 @@ async function main(argv) {
|
|
|
1614
2213
|
return 2;
|
|
1615
2214
|
}
|
|
1616
2215
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
2216
|
+
const invokedAsProgram = (() => {
|
|
2217
|
+
const entry = process.argv[1];
|
|
2218
|
+
if (!entry)
|
|
2219
|
+
return true;
|
|
2220
|
+
if (/(^|[\\/])(cli\.(js|mjs|ts)|decidio(\.cmd|\.ps1)?)$/i.test(entry))
|
|
2221
|
+
return true;
|
|
2222
|
+
try {
|
|
2223
|
+
return toFileUrl(realPath(entry)).href === toFileUrl(realPath(fromFileUrl(import.meta.url))).href;
|
|
2224
|
+
}
|
|
2225
|
+
catch {
|
|
2226
|
+
return true;
|
|
2227
|
+
}
|
|
2228
|
+
})();
|
|
2229
|
+
if (invokedAsProgram)
|
|
2230
|
+
main(process.argv).then(async (code) => {
|
|
2231
|
+
if (code !== 0)
|
|
2232
|
+
process.exitCode = code;
|
|
2233
|
+
if (stdinReadAbandoned) {
|
|
2234
|
+
await new Promise((r) => process.stdout.write("", () => r()));
|
|
2235
|
+
await new Promise((r) => process.stderr.write("", () => r()));
|
|
2236
|
+
process.exit(code);
|
|
2237
|
+
}
|
|
2238
|
+
}).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
|