@voltade/volty 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/volty.js +316 -356
- package/package.json +1 -1
package/dist/volty.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// bin/volty.ts
|
|
4
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as
|
|
4
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
|
|
5
5
|
import { homedir as homedir3 } from "node:os";
|
|
6
|
-
import { join as
|
|
6
|
+
import { join as join6 } from "node:path";
|
|
7
7
|
|
|
8
8
|
// src/api.ts
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
@@ -573,7 +573,7 @@ function renderResult(body, json) {
|
|
|
573
573
|
}
|
|
574
574
|
|
|
575
575
|
// src/keys.ts
|
|
576
|
-
var VALUE_FLAGS = new Set(["name", "for", "for-user-id", "expires", "expires-in-days", "families"]);
|
|
576
|
+
var VALUE_FLAGS = new Set(["name", "for", "for-user-id", "expires", "expires-in-days", "families", "cap"]);
|
|
577
577
|
function keysArgv(argv) {
|
|
578
578
|
const out = argv.slice(0, 2);
|
|
579
579
|
const rest = argv.slice(2);
|
|
@@ -592,6 +592,8 @@ function keysArgv(argv) {
|
|
|
592
592
|
out.push("--for-user-id", value);
|
|
593
593
|
else if (flag === "expires")
|
|
594
594
|
out.push("--expires-in-days", value.replace(/d$/, ""));
|
|
595
|
+
else if (flag === "cap")
|
|
596
|
+
out.push("--daily-cap", value);
|
|
595
597
|
else if (flag === "families")
|
|
596
598
|
for (const f of value.split(","))
|
|
597
599
|
out.push("--families", f.trim());
|
|
@@ -650,328 +652,23 @@ function decideProposal(ctx, proposalId, decision, reason) {
|
|
|
650
652
|
import { createHash as createHash2 } from "node:crypto";
|
|
651
653
|
import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync4, unlinkSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
652
654
|
import { dirname as dirname2, join as join3 } from "node:path";
|
|
653
|
-
async function
|
|
655
|
+
async function read(ctx, name, input = {}) {
|
|
654
656
|
const res = await runOp(ctx, { name }, input);
|
|
655
657
|
if (!res.ok)
|
|
656
658
|
throw new Error(`${name}: ${res.body?.error?.message ?? `HTTP ${res.status}`}`);
|
|
657
|
-
|
|
658
|
-
if (structured?.refused)
|
|
659
|
-
return { refused: res.body.content?.[0]?.text ?? `${name} refused` };
|
|
660
|
-
return { value: structured };
|
|
661
|
-
}
|
|
662
|
-
async function read(ctx, name, input = {}) {
|
|
663
|
-
const out = await call(ctx, name, input);
|
|
664
|
-
if ("refused" in out)
|
|
665
|
-
throw new Error(`${name}: ${out.refused}`);
|
|
666
|
-
return out.value;
|
|
667
|
-
}
|
|
668
|
-
function slugBase(name) {
|
|
669
|
-
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/^-+|-+$/g, "");
|
|
670
|
-
return slug || "source";
|
|
671
|
-
}
|
|
672
|
-
function tsLiteral(value, indent = "") {
|
|
673
|
-
if (value === null || typeof value !== "object")
|
|
674
|
-
return JSON.stringify(value) ?? "undefined";
|
|
675
|
-
const next = `${indent} `;
|
|
676
|
-
if (Array.isArray(value)) {
|
|
677
|
-
if (value.length === 0)
|
|
678
|
-
return "[]";
|
|
679
|
-
return `[
|
|
680
|
-
${value.map((v) => `${next}${tsLiteral(v, next)}`).join(`,
|
|
681
|
-
`)},
|
|
682
|
-
${indent}]`;
|
|
683
|
-
}
|
|
684
|
-
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
685
|
-
if (entries.length === 0)
|
|
686
|
-
return "{}";
|
|
687
|
-
const key = (k) => /^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k);
|
|
688
|
-
return `{
|
|
689
|
-
${entries.map(([k, v]) => `${next}${key(k)}: ${tsLiteral(v, next)}`).join(`,
|
|
690
|
-
`)},
|
|
691
|
-
${indent}}`;
|
|
692
|
-
}
|
|
693
|
-
function tsFile(header, value) {
|
|
694
|
-
return `// ${header[0]}
|
|
695
|
-
// ${header[1]}
|
|
696
|
-
export default ${tsLiteral(value)}
|
|
697
|
-
`;
|
|
659
|
+
return res.body.structuredContent;
|
|
698
660
|
}
|
|
699
661
|
var sha256 = (text) => createHash2("sha256").update(text).digest("hex");
|
|
700
|
-
|
|
701
|
-
class Observed {
|
|
702
|
-
sections = new Map;
|
|
703
|
-
add(heading, line) {
|
|
704
|
-
let lines = this.sections.get(heading);
|
|
705
|
-
if (!lines) {
|
|
706
|
-
lines = [];
|
|
707
|
-
this.sections.set(heading, lines);
|
|
708
|
-
}
|
|
709
|
-
lines.push(line);
|
|
710
|
-
}
|
|
711
|
-
render() {
|
|
712
|
-
const blocks = [...this.sections].map(([heading, lines]) => `${heading}
|
|
713
|
-
${lines.map((l) => `- ${l}`).join(`
|
|
714
|
-
`)}`);
|
|
715
|
-
return `${[OBSERVED_HEAD, ...blocks].join(`
|
|
716
|
-
|
|
717
|
-
`)}
|
|
718
|
-
`;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
var OBSERVED_HEAD = "Read-only. What this workspace holds outside the project as of the last volty pull: data Volty and staff write in the app, and what has no project file yet. Editing this file changes nothing, and volty pull rewrites it.";
|
|
722
|
-
var WAITING_HEADING = "Versions waiting for approval (the files hold the running version)";
|
|
723
|
-
var DRIVE_HEADING = "Drive files (the workspace Drive, which the project does not hold)";
|
|
724
|
-
var part = () => ({ files: new Map, observed: new Observed });
|
|
725
|
-
async function pooled(items, fn, size = 6) {
|
|
726
|
-
const out = new Array(items.length);
|
|
727
|
-
let next = 0;
|
|
728
|
-
const worker = async () => {
|
|
729
|
-
while (next < items.length) {
|
|
730
|
-
const i = next++;
|
|
731
|
-
out[i] = await fn(items[i]);
|
|
732
|
-
}
|
|
733
|
-
};
|
|
734
|
-
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
|
|
735
|
-
return out;
|
|
736
|
-
}
|
|
737
|
-
function ruleOut(rule) {
|
|
738
|
-
const { teamId, userId, userIds, ...action } = rule.action;
|
|
739
|
-
return {
|
|
740
|
-
enabled: rule.enabled,
|
|
741
|
-
condition: rule.condition,
|
|
742
|
-
action: {
|
|
743
|
-
...action,
|
|
744
|
-
...teamId ? { team: rule.teamSlug } : {},
|
|
745
|
-
...userId ? { member: rule.memberEmails[0] ?? null } : {},
|
|
746
|
-
...userIds ? { members: rule.memberEmails } : {}
|
|
747
|
-
}
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
async function pullInboxes(ctx) {
|
|
751
|
-
const out = { ...part(), slugById: new Map };
|
|
752
|
-
const { inboxes } = await read(ctx, "inboxes list");
|
|
753
|
-
const rows = inboxes.filter((i) => i.type === "shared").sort((a, b) => a.id.localeCompare(b.id));
|
|
754
|
-
const got = await pooled(rows, async (row) => {
|
|
755
|
-
if (row.role === null)
|
|
756
|
-
return null;
|
|
757
|
-
const inbox = await read(ctx, "inboxes get", { inboxId: row.id });
|
|
758
|
-
const flow = inbox.webChat ? await read(ctx, "chat-flow get", { inboxId: row.id, part: "source" }) : null;
|
|
759
|
-
return { inbox, flow };
|
|
760
|
-
});
|
|
761
|
-
rows.forEach((row, i) => {
|
|
762
|
-
const one = got[i];
|
|
763
|
-
if (!one) {
|
|
764
|
-
out.observed.add("Inboxes not pulled", `${row.name}: you are not on this inbox, so its settings are not readable`);
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
const { inbox, flow } = one;
|
|
768
|
-
out.slugById.set(row.id, inbox.slug);
|
|
769
|
-
const dir = `inboxes/${inbox.slug}`;
|
|
770
|
-
if (flow?.published)
|
|
771
|
-
out.files.set(`${dir}/quick-paths.ts`, flow.published.source);
|
|
772
|
-
if (flow && flow.revision > 0 && flow.source !== flow.published?.source) {
|
|
773
|
-
out.observed.add("Quick paths with unpublished changes (the file holds the published version)", `${dir}/quick-paths.ts: the draft at revision ${flow.revision} is not published`);
|
|
774
|
-
}
|
|
775
|
-
const { volty, webChat, routing } = inbox;
|
|
776
|
-
const { agentId: _agentId, agentName, ...seat } = volty ?? { agentId: "", agentName: "" };
|
|
777
|
-
const { handoffWhatsappInstanceId: _wa, handoffEmailInstanceId: _email, ...chat } = webChat?.settings ?? {};
|
|
778
|
-
out.files.set(`${dir}/inbox.ts`, tsFile([
|
|
779
|
-
`The ${inbox.name} inbox: hours, reply targets, hand-off, priority guide, routing, Volty's reply mode and its web chat.`,
|
|
780
|
-
"Written by volty pull from the live inbox. Who works the inbox, team members and channels stay in the app."
|
|
781
|
-
], {
|
|
782
|
-
name: inbox.name,
|
|
783
|
-
icon: inbox.icon,
|
|
784
|
-
autoEnrollOrgMembers: inbox.autoEnrollOrgMembers,
|
|
785
|
-
businessHours: inbox.businessHours,
|
|
786
|
-
idleClose: inbox.idleClose,
|
|
787
|
-
firstResponseMinutes: inbox.firstResponseMinutes,
|
|
788
|
-
handoff: inbox.handoff,
|
|
789
|
-
priority: inbox.priority,
|
|
790
|
-
routing: { ...routing, rules: routing.rules.map(ruleOut) },
|
|
791
|
-
volty: volty ? { agent: agentName, ...seat } : null,
|
|
792
|
-
webChat: webChat ? {
|
|
793
|
-
...chat,
|
|
794
|
-
handoffWhatsappNumber: webChat.handoffAddresses.whatsapp,
|
|
795
|
-
handoffEmailAddress: webChat.handoffAddresses.email
|
|
796
|
-
} : null,
|
|
797
|
-
quickPaths: flow ? { enabled: flow.enabled } : undefined
|
|
798
|
-
}));
|
|
799
|
-
});
|
|
800
|
-
return out;
|
|
801
|
-
}
|
|
802
|
-
async function pullInstructions(ctx, inboxes) {
|
|
803
|
-
const out = part();
|
|
804
|
-
const [got, { slugById }] = await Promise.all([read(ctx, "instructions get"), inboxes]);
|
|
805
|
-
if (got.org.text)
|
|
806
|
-
out.files.set("instructions/org.md", got.org.text);
|
|
807
|
-
if (got.team.text)
|
|
808
|
-
out.files.set("instructions/team.md", got.team.text);
|
|
809
|
-
for (const row of got.inboxes) {
|
|
810
|
-
const slug = slugById.get(row.inboxId);
|
|
811
|
-
if (slug && row.text)
|
|
812
|
-
out.files.set(`inboxes/${slug}/instructions.md`, row.text);
|
|
813
|
-
}
|
|
814
|
-
return out;
|
|
815
|
-
}
|
|
816
|
-
async function pullSources(ctx) {
|
|
817
|
-
const { sources } = await read(ctx, "sources list");
|
|
818
|
-
const out = { ...part(), sources };
|
|
819
|
-
const rows = [...sources].sort((a, b) => a.id.localeCompare(b.id));
|
|
820
|
-
const got = await pooled(rows, (row) => call(ctx, "sources get", { source: row.id, export: true }));
|
|
821
|
-
const taken = new Set;
|
|
822
|
-
rows.forEach((row, i) => {
|
|
823
|
-
const one = got[i];
|
|
824
|
-
if (!one || "refused" in one) {
|
|
825
|
-
out.observed.add("Sources with no file yet (website, API and sync sources)", `${row.name} (${row.kind})`);
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
const base = slugBase(row.name);
|
|
829
|
-
let slug = base;
|
|
830
|
-
for (let n = 2;taken.has(slug); n++)
|
|
831
|
-
slug = `${base}-${n}`;
|
|
832
|
-
taken.add(slug);
|
|
833
|
-
const { id: _id, scope, automation, ...rest } = one.value.file;
|
|
834
|
-
out.files.set(`sources/${slug}.ts`, tsFile([
|
|
835
|
-
`The ${row.name} source: its settings, the names of its secrets (never a value), lanes, scope and tool modes.`,
|
|
836
|
-
"Written by volty pull. Secret values, sign-ins and which tools an admin approved stay in the app."
|
|
837
|
-
], {
|
|
838
|
-
...rest,
|
|
839
|
-
scope: { mode: scope.mode, inboxes: scope.inboxes.map((i) => i.slug), privateInboxes: scope.privateInboxes },
|
|
840
|
-
automation: automation?.slug ?? null
|
|
841
|
-
}));
|
|
842
|
-
});
|
|
843
|
-
return out;
|
|
844
|
-
}
|
|
845
|
-
var notDeployed = (row) => `${row.slug}: ${row.pendingApproval ? "its first version waits for approval" : "no version is running"}`;
|
|
846
|
-
async function pullAutomations(ctx) {
|
|
847
|
-
const out = part();
|
|
848
|
-
const { automations } = await read(ctx, "automations list");
|
|
849
|
-
const got = await pooled(automations, async (row) => {
|
|
850
|
-
if (!row.deployed)
|
|
851
|
-
return null;
|
|
852
|
-
const first = await read(ctx, "automations get", { id: row.id });
|
|
853
|
-
if (first.sourceLabel !== "pending" || !first.active?.version)
|
|
854
|
-
return { got: first, waiting: false };
|
|
855
|
-
return {
|
|
856
|
-
got: await read(ctx, "automations get", { id: row.id, version: first.active.version }),
|
|
857
|
-
waiting: true
|
|
858
|
-
};
|
|
859
|
-
});
|
|
860
|
-
automations.forEach((row, i) => {
|
|
861
|
-
const one = got[i];
|
|
862
|
-
if (!one) {
|
|
863
|
-
out.observed.add("Automations not deployed", notDeployed(row));
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
if (one.waiting)
|
|
867
|
-
out.observed.add(WAITING_HEADING, `automations/${row.slug}.ts`);
|
|
868
|
-
if (one.got.source !== null)
|
|
869
|
-
out.files.set(`automations/${row.slug}.ts`, one.got.source);
|
|
870
|
-
});
|
|
871
|
-
return out;
|
|
872
|
-
}
|
|
873
|
-
async function pullPages(ctx) {
|
|
874
|
-
const out = part();
|
|
875
|
-
const { miniApps } = await read(ctx, "mini-apps list");
|
|
876
|
-
const got = await pooled(miniApps, async (row) => {
|
|
877
|
-
if (!row.deployed)
|
|
878
|
-
return null;
|
|
879
|
-
const first = await read(ctx, "mini-apps get", { id: row.id });
|
|
880
|
-
if (!first.pending)
|
|
881
|
-
return { got: first, waiting: false };
|
|
882
|
-
const active = first.deploys.find((d) => d.status === "active");
|
|
883
|
-
return {
|
|
884
|
-
got: active?.version ? await read(ctx, "mini-apps get", { id: row.id, version: active.version }) : first,
|
|
885
|
-
waiting: true
|
|
886
|
-
};
|
|
887
|
-
});
|
|
888
|
-
miniApps.forEach((row, i) => {
|
|
889
|
-
const one = got[i];
|
|
890
|
-
if (!one) {
|
|
891
|
-
out.observed.add("Pages not deployed", notDeployed(row));
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
if (one.waiting)
|
|
895
|
-
out.observed.add(WAITING_HEADING, `pages/${row.slug}/`);
|
|
896
|
-
if (one.got.entrySource !== null)
|
|
897
|
-
out.files.set(`pages/${row.slug}/entry.tsx`, one.got.entrySource);
|
|
898
|
-
if (one.got.bffSource !== null)
|
|
899
|
-
out.files.set(`pages/${row.slug}/bff.ts`, one.got.bffSource);
|
|
900
|
-
});
|
|
901
|
-
return out;
|
|
902
|
-
}
|
|
903
|
-
var DRIVE_FOLDER_CAP = 100;
|
|
904
|
-
async function walkDrive(ctx) {
|
|
905
|
-
const files = [];
|
|
906
|
-
let queue = ["/"];
|
|
907
|
-
let walked = 0;
|
|
908
|
-
while (queue.length > 0 && walked < DRIVE_FOLDER_CAP) {
|
|
909
|
-
const level = queue.slice(0, DRIVE_FOLDER_CAP - walked);
|
|
910
|
-
walked += level.length;
|
|
911
|
-
const listed = await pooled(level, (path) => read(ctx, "drive list-folder", { scope: "organization", path }));
|
|
912
|
-
const children = [];
|
|
913
|
-
for (const { entries } of listed) {
|
|
914
|
-
for (const entry of entries) {
|
|
915
|
-
if (entry.kind === "folder")
|
|
916
|
-
children.push(entry.path);
|
|
917
|
-
else
|
|
918
|
-
files.push(entry.path);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
queue = [...queue.slice(level.length), ...children];
|
|
922
|
-
}
|
|
923
|
-
return { files, unread: queue.length };
|
|
924
|
-
}
|
|
925
|
-
async function observeData(ctx) {
|
|
926
|
-
const out = part();
|
|
927
|
-
const [learned, drive, { tables }] = await Promise.all([
|
|
928
|
-
read(ctx, "learned list"),
|
|
929
|
-
walkDrive(ctx),
|
|
930
|
-
read(ctx, "tables list")
|
|
931
|
-
]);
|
|
932
|
-
for (const rule of learned.rules)
|
|
933
|
-
out.observed.add("Learned rules (Volty writes them; a push never touches them)", rule.text);
|
|
934
|
-
for (const path of drive.files)
|
|
935
|
-
out.observed.add(DRIVE_HEADING, path);
|
|
936
|
-
if (drive.unread > 0)
|
|
937
|
-
out.observed.add(DRIVE_HEADING, `${drive.unread} more folders not listed`);
|
|
938
|
-
for (const table of tables)
|
|
939
|
-
out.observed.add("Tables (their rows stay in the workspace)", table.name);
|
|
940
|
-
return out;
|
|
941
|
-
}
|
|
942
|
-
function agentsMd(workspaceId, sources) {
|
|
943
|
-
const systems = [...sources].sort((a, b) => a.name.localeCompare(b.name)).map((s) => `- ${s.name} (${s.kind})`);
|
|
944
|
-
return `# Volty project
|
|
945
|
-
|
|
946
|
-
This folder is one Volty workspace's setup as files, written by \`volty pull\` from the live workspace \`${workspaceId}\`.
|
|
947
|
-
|
|
948
|
-
- \`instructions/org.md\`: the instructions Volty reads everywhere. \`instructions/team.md\`: how Volty works with staff, read only when it talks to staff.
|
|
949
|
-
- \`inboxes/<slug>/inbox.ts\`: one inbox's settings. Beside it, \`instructions.md\` is that inbox's own instructions and \`quick-paths.ts\` its web chat's quick paths.
|
|
950
|
-
- \`sources/<slug>.ts\`: a connected source's settings, the names of its secrets (never a value), lanes and tool modes.
|
|
951
|
-
- \`automations/<slug>.ts\`: an automation's running source.
|
|
952
|
-
- \`pages/<slug>/\`: a page's \`entry.tsx\` and \`bff.ts\`.
|
|
953
|
-
- \`volty.config.ts\`: the workspace this project deploys to.
|
|
954
|
-
- \`.volty/targets\`: the version this folder was pulled from. \`.volty/observed\`: what the workspace holds outside the project (Learned rules, Drive files, tables, sources with no file yet), read-only.
|
|
955
|
-
|
|
956
|
-
Systems connected to this workspace:
|
|
957
|
-
${systems.length > 0 ? systems.join(`
|
|
958
|
-
`) : "- none"}
|
|
959
|
-
|
|
960
|
-
Pushing the folder back is not built yet. Run \`volty pull\` again to bring the folder up to date, and \`volty docs\` for every operation.
|
|
961
|
-
`;
|
|
962
|
-
}
|
|
963
|
-
function configFile(target, workspaceId) {
|
|
964
|
-
return tsFile([
|
|
965
|
-
"Where this project deploys: each target names one workspace by id.",
|
|
966
|
-
"Written by volty pull; the version each target was pulled from is in .volty/targets."
|
|
967
|
-
], { targets: { [target]: { workspace: workspaceId } } });
|
|
968
|
-
}
|
|
969
662
|
function readTargets(dir) {
|
|
970
663
|
const path = join3(dir, ".volty", "targets");
|
|
971
664
|
if (!existsSync4(path))
|
|
972
665
|
return null;
|
|
973
666
|
return JSON.parse(readFileSync4(path, "utf8"));
|
|
974
667
|
}
|
|
668
|
+
function writeTargets(dir, targets) {
|
|
669
|
+
writeFileSync4(join3(dir, ".volty", "targets"), `${JSON.stringify(targets, null, 2)}
|
|
670
|
+
`);
|
|
671
|
+
}
|
|
975
672
|
async function pull(ctx, opts) {
|
|
976
673
|
const { dir, workspaceId, target } = opts;
|
|
977
674
|
const targets = readTargets(dir);
|
|
@@ -983,32 +680,14 @@ async function pull(ctx, opts) {
|
|
|
983
680
|
} else if (existsSync4(dir) && readdirSync(dir).some((name) => !name.startsWith("."))) {
|
|
984
681
|
throw new Error(`${dir} is not empty and no volty pull wrote it: pull into an empty folder.`);
|
|
985
682
|
}
|
|
986
|
-
const inboxes = pullInboxes(ctx);
|
|
987
|
-
const parts = await Promise.all([
|
|
988
|
-
inboxes,
|
|
989
|
-
pullInstructions(ctx, inboxes),
|
|
990
|
-
pullSources(ctx),
|
|
991
|
-
pullAutomations(ctx),
|
|
992
|
-
pullPages(ctx),
|
|
993
|
-
observeData(ctx)
|
|
994
|
-
]);
|
|
995
|
-
const files = new Map;
|
|
996
|
-
const observed = new Observed;
|
|
997
|
-
for (const one of parts) {
|
|
998
|
-
for (const [rel, text] of one.files)
|
|
999
|
-
files.set(rel, text);
|
|
1000
|
-
for (const [heading, lines] of one.observed.sections)
|
|
1001
|
-
for (const line of lines)
|
|
1002
|
-
observed.add(heading, line);
|
|
1003
|
-
}
|
|
1004
|
-
files.set("AGENTS.md", agentsMd(workspaceId, parts[2].sources));
|
|
1005
|
-
files.set("volty.config.ts", configFile(target, workspaceId));
|
|
1006
683
|
const base = targets?.[target]?.version;
|
|
1007
|
-
const {
|
|
1008
|
-
|
|
1009
|
-
files:
|
|
1010
|
-
|
|
1011
|
-
|
|
684
|
+
const {
|
|
685
|
+
baseFiles,
|
|
686
|
+
files: tree,
|
|
687
|
+
observed,
|
|
688
|
+
...recorded
|
|
689
|
+
} = await read(ctx, "project pull", { workspace: workspaceId, ...base ? { base } : {} });
|
|
690
|
+
const files = new Map(Object.entries(tree));
|
|
1012
691
|
const written = [];
|
|
1013
692
|
const deleted = [];
|
|
1014
693
|
const kept = [];
|
|
@@ -1038,30 +717,247 @@ async function pull(ctx, opts) {
|
|
|
1038
717
|
}
|
|
1039
718
|
}
|
|
1040
719
|
mkdirSync3(join3(dir, ".volty"), { recursive: true });
|
|
1041
|
-
|
|
1042
|
-
...targets,
|
|
1043
|
-
[target]: { workspace: recorded.workspaceId, version: recorded.versionId }
|
|
1044
|
-
};
|
|
1045
|
-
writeFileSync4(join3(dir, ".volty", "targets"), `${JSON.stringify(nextTargets, null, 2)}
|
|
1046
|
-
`);
|
|
720
|
+
writeTargets(dir, { ...targets, [target]: { workspace: recorded.workspaceId, version: recorded.versionId } });
|
|
1047
721
|
const observedPath = join3(dir, ".volty", "observed");
|
|
1048
722
|
if (existsSync4(observedPath))
|
|
1049
723
|
chmodSync(observedPath, 420);
|
|
1050
|
-
writeFileSync4(observedPath, observed
|
|
724
|
+
writeFileSync4(observedPath, observed);
|
|
1051
725
|
chmodSync(observedPath, 292);
|
|
1052
726
|
return { ...recorded, written, deleted, kept };
|
|
1053
727
|
}
|
|
1054
728
|
|
|
729
|
+
// src/push.ts
|
|
730
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
|
|
731
|
+
import { join as join4 } from "node:path";
|
|
732
|
+
|
|
733
|
+
// ../shared/projects/limits.ts
|
|
734
|
+
var PROJECT_FILE_MAX_CHARS = 2000000;
|
|
735
|
+
var PROJECT_FILES_MAX = 2000;
|
|
736
|
+
|
|
737
|
+
// ../shared/text/plural.ts
|
|
738
|
+
function plural(n, noun) {
|
|
739
|
+
return n === 1 ? noun : `${noun}s`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/push.ts
|
|
743
|
+
function readProject(dir) {
|
|
744
|
+
const targets = readTargets(dir);
|
|
745
|
+
if (!targets) {
|
|
746
|
+
throw new Error(`${dir} has no .volty/targets, so no volty pull wrote it: pull the workspace into it first.`);
|
|
747
|
+
}
|
|
748
|
+
const files = {};
|
|
749
|
+
let count = 0;
|
|
750
|
+
const walk = (rel) => {
|
|
751
|
+
for (const entry of readdirSync2(join4(dir, rel), { withFileTypes: true })) {
|
|
752
|
+
if (entry.name === "node_modules" || entry.name === ".git")
|
|
753
|
+
continue;
|
|
754
|
+
if (rel === "" && entry.name.startsWith("."))
|
|
755
|
+
continue;
|
|
756
|
+
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
757
|
+
if (entry.isDirectory()) {
|
|
758
|
+
walk(path);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (!entry.isFile())
|
|
762
|
+
continue;
|
|
763
|
+
if (++count > PROJECT_FILES_MAX) {
|
|
764
|
+
throw new Error(`${dir} holds more than ${PROJECT_FILES_MAX.toLocaleString("en-US")} files, the most a push takes.`);
|
|
765
|
+
}
|
|
766
|
+
const text = readFileSync5(join4(dir, path), "utf8");
|
|
767
|
+
if (text.length > PROJECT_FILE_MAX_CHARS) {
|
|
768
|
+
throw new Error(`${path} holds ${text.length.toLocaleString("en-US")} characters, over the ${PROJECT_FILE_MAX_CHARS.toLocaleString("en-US")} a push takes per file.`);
|
|
769
|
+
}
|
|
770
|
+
files[path] = text;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
walk("");
|
|
774
|
+
return { targets, files };
|
|
775
|
+
}
|
|
776
|
+
function pickTargets(targets, env) {
|
|
777
|
+
const names = Object.keys(targets);
|
|
778
|
+
if (env === "all")
|
|
779
|
+
return names;
|
|
780
|
+
if (env !== undefined) {
|
|
781
|
+
if (!targets[env])
|
|
782
|
+
throw new Error(`.volty/targets has no target ${env}: it has ${names.join(", ") || "none"}.`);
|
|
783
|
+
return [env];
|
|
784
|
+
}
|
|
785
|
+
if (names.length === 1)
|
|
786
|
+
return names;
|
|
787
|
+
throw new Error(`.volty/targets has ${names.length} targets: name one with --env (${names.join(", ")}) or --env all.`);
|
|
788
|
+
}
|
|
789
|
+
function renderPlan(plan) {
|
|
790
|
+
const out = [];
|
|
791
|
+
for (const item of plan.items) {
|
|
792
|
+
if (item.change === "none" && item.lines.length === 0 && item.findings.length === 0)
|
|
793
|
+
continue;
|
|
794
|
+
const [first, ...more] = item.lines;
|
|
795
|
+
const head = item.change !== "none" ? item.change : first ?? "no change";
|
|
796
|
+
out.push(`${item.name}: ${head}`);
|
|
797
|
+
for (const line of item.change !== "none" ? item.lines : more)
|
|
798
|
+
out.push(` ${line}`);
|
|
799
|
+
for (const line of item.liveSince)
|
|
800
|
+
out.push(` live since your pull: ${line}`);
|
|
801
|
+
for (const f of item.findings)
|
|
802
|
+
out.push(` ${f.severity}: ${f.message}`);
|
|
803
|
+
}
|
|
804
|
+
out.push(plan.summary);
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
function refusalOf(plan, confirm) {
|
|
808
|
+
if (!plan.runnable) {
|
|
809
|
+
const why = [];
|
|
810
|
+
if (plan.stale) {
|
|
811
|
+
const stale = plan.items.filter((i) => i.change !== "none" && i.liveSince.length > 0).map((i) => i.name);
|
|
812
|
+
why.push(`${stale.length > 0 ? stale.join(", ") : "The workspace"} changed live since your pull: pull again, then push.`);
|
|
813
|
+
}
|
|
814
|
+
for (const item of plan.items) {
|
|
815
|
+
for (const f of item.findings)
|
|
816
|
+
if (f.severity === "error")
|
|
817
|
+
why.push(`${item.name}: ${f.message}`);
|
|
818
|
+
}
|
|
819
|
+
if (why.length === 0)
|
|
820
|
+
why.push(plan.summary);
|
|
821
|
+
return `Nothing was pushed. ${why.join(`
|
|
822
|
+
`)}`;
|
|
823
|
+
}
|
|
824
|
+
const destructive = plan.items.filter((i) => i.destructive && i.change !== "none").map((i) => i.name);
|
|
825
|
+
if (destructive.length > 0 && !confirm) {
|
|
826
|
+
return `Nothing was pushed: ${destructive.join(", ")} ${destructive.length === 1 ? "is" : "are"} destructive. Read the plan, then push again with --confirm.`;
|
|
827
|
+
}
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
async function applyItem(ctx, item) {
|
|
831
|
+
const base = { path: item.path, name: item.name, lines: item.lines };
|
|
832
|
+
let pending = false;
|
|
833
|
+
for (const step of item.steps) {
|
|
834
|
+
let res;
|
|
835
|
+
try {
|
|
836
|
+
res = await runOp(ctx, { name: step.op }, step.input);
|
|
837
|
+
} catch (err) {
|
|
838
|
+
const detail = `${step.op}: ${err instanceof Error ? err.message : String(err)}`;
|
|
839
|
+
return { result: { ...base, result: "failed", detail }, stop: true };
|
|
840
|
+
}
|
|
841
|
+
if (res.status === 202) {
|
|
842
|
+
const changeId = res.body.structuredContent?.proposalId;
|
|
843
|
+
return { result: { ...base, result: "held", ...changeId ? { changeId } : {} }, stop: false };
|
|
844
|
+
}
|
|
845
|
+
if (res.status === 422) {
|
|
846
|
+
const detail = res.body?.error?.message ?? `${step.op} refused`;
|
|
847
|
+
return { result: { ...base, result: "refused", detail }, stop: false };
|
|
848
|
+
}
|
|
849
|
+
if (res.status !== 200) {
|
|
850
|
+
const detail = `${step.op}: ${res.body?.error?.message ?? `HTTP ${res.status}`}`;
|
|
851
|
+
return { result: { ...base, result: "failed", detail }, stop: true };
|
|
852
|
+
}
|
|
853
|
+
const answer = res.body.structuredContent;
|
|
854
|
+
if (answer?.deployStatus === "changes_requested") {
|
|
855
|
+
const detail = res.body.content?.map((c) => c.text ?? "").join(`
|
|
856
|
+
`) || `${step.op} was sent back by the reviewer`;
|
|
857
|
+
return { result: { ...base, result: "refused", detail }, stop: false };
|
|
858
|
+
}
|
|
859
|
+
if (answer?.deployStatus === "pending_approval")
|
|
860
|
+
pending = true;
|
|
861
|
+
}
|
|
862
|
+
return { result: { ...base, result: pending ? "pending" : "applied" }, stop: false };
|
|
863
|
+
}
|
|
864
|
+
function resultLine(r) {
|
|
865
|
+
switch (r.result) {
|
|
866
|
+
case "applied":
|
|
867
|
+
return `${r.name}: applied`;
|
|
868
|
+
case "pending":
|
|
869
|
+
return `${r.name}: pending approval, the running version stays live until an admin approves`;
|
|
870
|
+
case "held":
|
|
871
|
+
return `${r.name}: held as change ${r.changeId ?? "(no id)"} for an admin. The rest of this item did not run and will not run on approval, so push again once it is decided (volty changes lists it)`;
|
|
872
|
+
case "refused":
|
|
873
|
+
return `${r.name}: refused: ${r.detail}`;
|
|
874
|
+
case "failed":
|
|
875
|
+
return `${r.name}: failed: ${r.detail}`;
|
|
876
|
+
case "skipped":
|
|
877
|
+
return r.detail ? `${r.name}: not applied, ${r.detail}` : `${r.name}: skipped, after the failure above`;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
async function pushTarget(ctx, opts) {
|
|
881
|
+
const { dir, target, files, targets } = opts;
|
|
882
|
+
const { workspace, version } = targets[target];
|
|
883
|
+
const base = version ? { base: version } : {};
|
|
884
|
+
const plan = await read(ctx, "project push", { workspace, target, files, ...base });
|
|
885
|
+
const lines = renderPlan(plan);
|
|
886
|
+
const refusal = refusalOf(plan, opts.confirm || opts.dryRun);
|
|
887
|
+
if (opts.dryRun)
|
|
888
|
+
return { exitCode: plan.runnable ? 0 : 1, lines, json: plan, ...refusal ? { error: refusal } : {} };
|
|
889
|
+
if (refusal)
|
|
890
|
+
return { exitCode: 1, lines, json: plan, error: refusal };
|
|
891
|
+
const results = [];
|
|
892
|
+
let stopped = false;
|
|
893
|
+
for (const item of plan.items) {
|
|
894
|
+
if (item.change === "none")
|
|
895
|
+
continue;
|
|
896
|
+
if (stopped) {
|
|
897
|
+
results.push({ path: item.path, name: item.name, lines: item.lines, result: "skipped" });
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (item.steps.length === 0) {
|
|
901
|
+
const detail = item.findings.find((f) => f.severity === "warn")?.message ?? "nothing to run for this kind yet";
|
|
902
|
+
results.push({ path: item.path, name: item.name, lines: item.lines, result: "skipped", detail });
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
const { result, stop } = await applyItem(ctx, item);
|
|
906
|
+
results.push(result);
|
|
907
|
+
stopped = stop;
|
|
908
|
+
}
|
|
909
|
+
if (results.length === 0)
|
|
910
|
+
return { exitCode: 0, lines, json: plan };
|
|
911
|
+
const resultsOut = results.map((r) => resultLine(r));
|
|
912
|
+
if (results.every((r) => r.result === "skipped")) {
|
|
913
|
+
const n = results.length;
|
|
914
|
+
return {
|
|
915
|
+
exitCode: 1,
|
|
916
|
+
lines: [...lines, ...resultsOut],
|
|
917
|
+
json: { items: results },
|
|
918
|
+
error: `Nothing was pushed: ${n} ${plural(n, "item")} ${n === 1 ? "has" : "have"} nothing to run yet.`
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
let record;
|
|
922
|
+
try {
|
|
923
|
+
record = await read(ctx, "project record-push", {
|
|
924
|
+
workspace,
|
|
925
|
+
target,
|
|
926
|
+
files,
|
|
927
|
+
...base,
|
|
928
|
+
items: results
|
|
929
|
+
});
|
|
930
|
+
} catch (err) {
|
|
931
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
932
|
+
return {
|
|
933
|
+
exitCode: 1,
|
|
934
|
+
lines: [...lines, ...resultsOut],
|
|
935
|
+
json: { items: results },
|
|
936
|
+
error: `The items above ran, but recording the push failed (${message}). .volty/targets keeps the old base: push again.`
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
targets[target] = { workspace, version: record.versionId };
|
|
940
|
+
writeTargets(dir, targets);
|
|
941
|
+
const count = (o) => results.filter((r) => r.result === o).length;
|
|
942
|
+
const skipped = count("skipped");
|
|
943
|
+
const summary = `Pushed ${results.length} ${plural(results.length, "item")} to ${target}: ${count("applied")} applied, ${count("held")} held, ${count("pending")} pending approval, ${count("refused")} refused, ${count("failed")} failed${skipped > 0 ? `, ${skipped} skipped` : ""}.`;
|
|
944
|
+
return {
|
|
945
|
+
exitCode: record.status === "partial" ? 1 : 0,
|
|
946
|
+
lines: [...lines, ...resultsOut, summary],
|
|
947
|
+
json: { ...record, items: results }
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
1055
951
|
// src/update.ts
|
|
1056
|
-
import { mkdirSync as mkdirSync4, readFileSync as
|
|
952
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
|
|
1057
953
|
import { homedir as homedir2 } from "node:os";
|
|
1058
|
-
import { dirname as dirname3, join as
|
|
1059
|
-
var CACHE_PATH =
|
|
954
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
955
|
+
var CACHE_PATH = join5(homedir2(), ".config", "volty", "update-check.json");
|
|
1060
956
|
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
1061
957
|
var REGISTRY_LATEST = "https://registry.npmjs.org/@voltade/volty/latest";
|
|
1062
958
|
function currentVersion() {
|
|
1063
959
|
try {
|
|
1064
|
-
const pkg = JSON.parse(
|
|
960
|
+
const pkg = JSON.parse(readFileSync6(join5(import.meta.dirname, "..", "package.json"), "utf8"));
|
|
1065
961
|
return pkg.version ?? null;
|
|
1066
962
|
} catch {
|
|
1067
963
|
return null;
|
|
@@ -1079,7 +975,7 @@ function cmpVersion(a, b) {
|
|
|
1079
975
|
}
|
|
1080
976
|
function readCache() {
|
|
1081
977
|
try {
|
|
1082
|
-
return JSON.parse(
|
|
978
|
+
return JSON.parse(readFileSync6(CACHE_PATH, "utf8"));
|
|
1083
979
|
} catch {
|
|
1084
980
|
return null;
|
|
1085
981
|
}
|
|
@@ -1344,6 +1240,65 @@ async function handlePull(rest, ctx, env) {
|
|
|
1344
1240
|
`)}
|
|
1345
1241
|
`);
|
|
1346
1242
|
}
|
|
1243
|
+
var PUSH_USAGE = "usage: volty push [dir] [--env <target>|all] [--dry-run] [--confirm] [--json]";
|
|
1244
|
+
async function handlePush(argv, config, envFlag) {
|
|
1245
|
+
const dryRun = takeBool(argv, "--dry-run");
|
|
1246
|
+
const confirm = takeBool(dryRun.rest, "--confirm");
|
|
1247
|
+
const json = takeBool(confirm.rest, "--json");
|
|
1248
|
+
const [dir = ".", ...extra] = json.rest;
|
|
1249
|
+
if (extra.length > 0 || dir.startsWith("--"))
|
|
1250
|
+
fail(PUSH_USAGE);
|
|
1251
|
+
const project = readProject(dir);
|
|
1252
|
+
const names = pickTargets(project.targets, envFlag);
|
|
1253
|
+
const answers = {};
|
|
1254
|
+
let exitCode = 0;
|
|
1255
|
+
for (const name of names) {
|
|
1256
|
+
const login = config.contexts[name];
|
|
1257
|
+
if (!login?.apiKey)
|
|
1258
|
+
fail(`not logged in to '${name}', the env target ${name} runs on: run \`volty auth login --env ${name}\``);
|
|
1259
|
+
const { workspace } = project.targets[name];
|
|
1260
|
+
const selected = await ensureOrgKey(login, workspace, (line) => process.stderr.write(`${line}
|
|
1261
|
+
`));
|
|
1262
|
+
if (selected.minted)
|
|
1263
|
+
saveConfig(config);
|
|
1264
|
+
const ctx = { ...login, apiKey: selected.apiKey, organizationId: selected.organizationId };
|
|
1265
|
+
setOrgOverride(selected.organizationId);
|
|
1266
|
+
if (isProdShaped(name, login.baseUrl)) {
|
|
1267
|
+
const res = await whoami(ctx);
|
|
1268
|
+
if ("error" in res)
|
|
1269
|
+
fail(res.error);
|
|
1270
|
+
process.stderr.write(`→ ${name} · ${res.who.organizationName} (${res.who.organizationId}) · ${res.who.role}
|
|
1271
|
+
`);
|
|
1272
|
+
}
|
|
1273
|
+
if (names.length > 1 && !json.present)
|
|
1274
|
+
process.stdout.write(`Target ${name}, workspace ${workspace}:
|
|
1275
|
+
`);
|
|
1276
|
+
const out = await pushTarget(ctx, {
|
|
1277
|
+
dir,
|
|
1278
|
+
target: name,
|
|
1279
|
+
...project,
|
|
1280
|
+
dryRun: dryRun.present,
|
|
1281
|
+
confirm: confirm.present
|
|
1282
|
+
});
|
|
1283
|
+
answers[name] = out.json;
|
|
1284
|
+
if (!json.present)
|
|
1285
|
+
process.stdout.write(`${out.lines.join(`
|
|
1286
|
+
`)}
|
|
1287
|
+
`);
|
|
1288
|
+
if (out.error)
|
|
1289
|
+
process.stderr.write(`volty: ${out.error}
|
|
1290
|
+
`);
|
|
1291
|
+
exitCode = out.exitCode;
|
|
1292
|
+
if (exitCode !== 0)
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
if (json.present) {
|
|
1296
|
+
const body = names.length === 1 ? answers[names[0]] : answers;
|
|
1297
|
+
process.stdout.write(`${JSON.stringify(body, null, 2)}
|
|
1298
|
+
`);
|
|
1299
|
+
}
|
|
1300
|
+
process.exitCode = exitCode;
|
|
1301
|
+
}
|
|
1347
1302
|
async function handleDocs(config, env) {
|
|
1348
1303
|
const ctx = ensureContext(config, env);
|
|
1349
1304
|
if (!ctx.baseUrl)
|
|
@@ -1357,16 +1312,16 @@ function handleSkill(rest) {
|
|
|
1357
1312
|
if (rest[0] !== "install")
|
|
1358
1313
|
fail("usage: volty skill install");
|
|
1359
1314
|
const candidates = [
|
|
1360
|
-
|
|
1361
|
-
|
|
1315
|
+
join6(import.meta.dirname, "..", "skill", "volty-cli-ops", "SKILL.md"),
|
|
1316
|
+
join6(import.meta.dirname, "..", "..", ".claude", "skills", "volty-cli-ops", "SKILL.md")
|
|
1362
1317
|
];
|
|
1363
1318
|
const source = candidates.find((p) => existsSync5(p));
|
|
1364
1319
|
if (!source)
|
|
1365
1320
|
fail("bundled SKILL.md not found — reinstall the @voltade/volty package");
|
|
1366
|
-
const destDir =
|
|
1321
|
+
const destDir = join6(homedir3(), ".claude", "skills", "volty-cli-ops");
|
|
1367
1322
|
mkdirSync5(destDir, { recursive: true });
|
|
1368
|
-
writeFileSync6(
|
|
1369
|
-
process.stdout.write(`Installed the volty-cli-ops skill → ${
|
|
1323
|
+
writeFileSync6(join6(destDir, "SKILL.md"), readFileSync7(source, "utf8"));
|
|
1324
|
+
process.stdout.write(`Installed the volty-cli-ops skill → ${join6(destDir, "SKILL.md")}
|
|
1370
1325
|
`);
|
|
1371
1326
|
}
|
|
1372
1327
|
async function main() {
|
|
@@ -1380,6 +1335,11 @@ async function main() {
|
|
|
1380
1335
|
const workspaceExtract = argv[0] === "pull" ? takeValue(argv, "--workspace") : { value: undefined, rest: argv };
|
|
1381
1336
|
argv = workspaceExtract.rest;
|
|
1382
1337
|
const config = loadConfig();
|
|
1338
|
+
if (argv[0] === "push") {
|
|
1339
|
+
if (orgExtract.value)
|
|
1340
|
+
fail("volty push takes its workspace from .volty/targets: leave out --org");
|
|
1341
|
+
return handlePush(argv.slice(1), config, envExtract.value ?? (process.env.VOLTY_ENV || undefined));
|
|
1342
|
+
}
|
|
1383
1343
|
const env = activeEnv(config, envExtract.value ?? (process.env.VOLTY_ENV || undefined));
|
|
1384
1344
|
const peek = config.contexts[env];
|
|
1385
1345
|
const prod = isProdShaped(env, peek?.baseUrl);
|
package/package.json
CHANGED