@voltade/volty 0.1.21 → 0.1.23
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 +465 -18
- 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
|
|
4
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "node:fs";
|
|
5
5
|
import { homedir as homedir3 } from "node:os";
|
|
6
|
-
import { join as
|
|
6
|
+
import { join as join5 } 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());
|
|
@@ -646,16 +648,422 @@ function decideProposal(ctx, proposalId, decision, reason) {
|
|
|
646
648
|
});
|
|
647
649
|
}
|
|
648
650
|
|
|
651
|
+
// src/pull.ts
|
|
652
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
653
|
+
import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync4, unlinkSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
654
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
655
|
+
async function call(ctx, name, input = {}) {
|
|
656
|
+
const res = await runOp(ctx, { name }, input);
|
|
657
|
+
if (res.status === 422 && res.body?.error?.code === "refused") {
|
|
658
|
+
return { refused: res.body.error.message ?? `${name} refused` };
|
|
659
|
+
}
|
|
660
|
+
if (!res.ok)
|
|
661
|
+
throw new Error(`${name}: ${res.body?.error?.message ?? `HTTP ${res.status}`}`);
|
|
662
|
+
return { value: res.body.structuredContent };
|
|
663
|
+
}
|
|
664
|
+
async function read(ctx, name, input = {}) {
|
|
665
|
+
const out = await call(ctx, name, input);
|
|
666
|
+
if ("refused" in out)
|
|
667
|
+
throw new Error(`${name}: ${out.refused}`);
|
|
668
|
+
return out.value;
|
|
669
|
+
}
|
|
670
|
+
function slugBase(name) {
|
|
671
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/^-+|-+$/g, "");
|
|
672
|
+
return slug || "source";
|
|
673
|
+
}
|
|
674
|
+
function tsLiteral(value, indent = "") {
|
|
675
|
+
if (value === null || typeof value !== "object")
|
|
676
|
+
return JSON.stringify(value) ?? "undefined";
|
|
677
|
+
const next = `${indent} `;
|
|
678
|
+
if (Array.isArray(value)) {
|
|
679
|
+
if (value.length === 0)
|
|
680
|
+
return "[]";
|
|
681
|
+
return `[
|
|
682
|
+
${value.map((v) => `${next}${tsLiteral(v, next)}`).join(`,
|
|
683
|
+
`)},
|
|
684
|
+
${indent}]`;
|
|
685
|
+
}
|
|
686
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
687
|
+
if (entries.length === 0)
|
|
688
|
+
return "{}";
|
|
689
|
+
const key = (k) => /^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k);
|
|
690
|
+
return `{
|
|
691
|
+
${entries.map(([k, v]) => `${next}${key(k)}: ${tsLiteral(v, next)}`).join(`,
|
|
692
|
+
`)},
|
|
693
|
+
${indent}}`;
|
|
694
|
+
}
|
|
695
|
+
function tsFile(header, value) {
|
|
696
|
+
return `// ${header[0]}
|
|
697
|
+
// ${header[1]}
|
|
698
|
+
export default ${tsLiteral(value)}
|
|
699
|
+
`;
|
|
700
|
+
}
|
|
701
|
+
var sha256 = (text) => createHash2("sha256").update(text).digest("hex");
|
|
702
|
+
|
|
703
|
+
class Observed {
|
|
704
|
+
sections = new Map;
|
|
705
|
+
add(heading, line) {
|
|
706
|
+
let lines = this.sections.get(heading);
|
|
707
|
+
if (!lines) {
|
|
708
|
+
lines = [];
|
|
709
|
+
this.sections.set(heading, lines);
|
|
710
|
+
}
|
|
711
|
+
lines.push(line);
|
|
712
|
+
}
|
|
713
|
+
render() {
|
|
714
|
+
const blocks = [...this.sections].map(([heading, lines]) => `${heading}
|
|
715
|
+
${lines.map((l) => `- ${l}`).join(`
|
|
716
|
+
`)}`);
|
|
717
|
+
return `${[OBSERVED_HEAD, ...blocks].join(`
|
|
718
|
+
|
|
719
|
+
`)}
|
|
720
|
+
`;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
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.";
|
|
724
|
+
var WAITING_HEADING = "Versions waiting for approval (the files hold the running version)";
|
|
725
|
+
var DRIVE_HEADING = "Drive files (the workspace Drive, which the project does not hold)";
|
|
726
|
+
var part = () => ({ files: new Map, observed: new Observed });
|
|
727
|
+
async function pooled(items, fn, size = 6) {
|
|
728
|
+
const out = new Array(items.length);
|
|
729
|
+
let next = 0;
|
|
730
|
+
const worker = async () => {
|
|
731
|
+
while (next < items.length) {
|
|
732
|
+
const i = next++;
|
|
733
|
+
out[i] = await fn(items[i]);
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
function ruleOut(rule) {
|
|
740
|
+
const { teamId, userId, userIds, ...action } = rule.action;
|
|
741
|
+
return {
|
|
742
|
+
enabled: rule.enabled,
|
|
743
|
+
condition: rule.condition,
|
|
744
|
+
action: {
|
|
745
|
+
...action,
|
|
746
|
+
...teamId ? { team: rule.teamSlug } : {},
|
|
747
|
+
...userId ? { member: rule.memberEmails[0] ?? null } : {},
|
|
748
|
+
...userIds ? { members: rule.memberEmails } : {}
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
async function pullInboxes(ctx) {
|
|
753
|
+
const out = { ...part(), slugById: new Map };
|
|
754
|
+
const { inboxes } = await read(ctx, "inboxes list");
|
|
755
|
+
const rows = inboxes.filter((i) => i.type === "shared").sort((a, b) => a.id.localeCompare(b.id));
|
|
756
|
+
const got = await pooled(rows, async (row) => {
|
|
757
|
+
if (row.role === null)
|
|
758
|
+
return null;
|
|
759
|
+
const inbox = await read(ctx, "inboxes get", { inboxId: row.id });
|
|
760
|
+
const flow = inbox.webChat ? await read(ctx, "chat-flow get", { inboxId: row.id, part: "source" }) : null;
|
|
761
|
+
return { inbox, flow };
|
|
762
|
+
});
|
|
763
|
+
rows.forEach((row, i) => {
|
|
764
|
+
const one = got[i];
|
|
765
|
+
if (!one) {
|
|
766
|
+
out.observed.add("Inboxes not pulled", `${row.name}: you are not on this inbox, so its settings are not readable`);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const { inbox, flow } = one;
|
|
770
|
+
out.slugById.set(row.id, inbox.slug);
|
|
771
|
+
const dir = `inboxes/${inbox.slug}`;
|
|
772
|
+
if (flow?.published)
|
|
773
|
+
out.files.set(`${dir}/quick-paths.ts`, flow.published.source);
|
|
774
|
+
if (flow && flow.revision > 0 && flow.source !== flow.published?.source) {
|
|
775
|
+
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`);
|
|
776
|
+
}
|
|
777
|
+
const { volty, webChat, routing } = inbox;
|
|
778
|
+
const { agentId: _agentId, agentName, ...seat } = volty ?? { agentId: "", agentName: "" };
|
|
779
|
+
const { handoffWhatsappInstanceId: _wa, handoffEmailInstanceId: _email, ...chat } = webChat?.settings ?? {};
|
|
780
|
+
out.files.set(`${dir}/inbox.ts`, tsFile([
|
|
781
|
+
`The ${inbox.name} inbox: hours, reply targets, hand-off, priority guide, routing, Volty's reply mode and its web chat.`,
|
|
782
|
+
"Written by volty pull from the live inbox. Who works the inbox, team members and channels stay in the app."
|
|
783
|
+
], {
|
|
784
|
+
name: inbox.name,
|
|
785
|
+
icon: inbox.icon,
|
|
786
|
+
autoEnrollOrgMembers: inbox.autoEnrollOrgMembers,
|
|
787
|
+
businessHours: inbox.businessHours,
|
|
788
|
+
idleClose: inbox.idleClose,
|
|
789
|
+
firstResponseMinutes: inbox.firstResponseMinutes,
|
|
790
|
+
handoff: inbox.handoff,
|
|
791
|
+
priority: inbox.priority,
|
|
792
|
+
routing: { ...routing, rules: routing.rules.map(ruleOut) },
|
|
793
|
+
volty: volty ? { agent: agentName, ...seat } : null,
|
|
794
|
+
webChat: webChat ? {
|
|
795
|
+
...chat,
|
|
796
|
+
handoffWhatsappNumber: webChat.handoffAddresses.whatsapp,
|
|
797
|
+
handoffEmailAddress: webChat.handoffAddresses.email
|
|
798
|
+
} : null,
|
|
799
|
+
quickPaths: flow ? { enabled: flow.enabled } : undefined
|
|
800
|
+
}));
|
|
801
|
+
});
|
|
802
|
+
return out;
|
|
803
|
+
}
|
|
804
|
+
async function pullInstructions(ctx, inboxes) {
|
|
805
|
+
const out = part();
|
|
806
|
+
const [got, { slugById }] = await Promise.all([read(ctx, "instructions get"), inboxes]);
|
|
807
|
+
if (got.org.text)
|
|
808
|
+
out.files.set("instructions/org.md", got.org.text);
|
|
809
|
+
if (got.team.text)
|
|
810
|
+
out.files.set("instructions/team.md", got.team.text);
|
|
811
|
+
for (const row of got.inboxes) {
|
|
812
|
+
const slug = slugById.get(row.inboxId);
|
|
813
|
+
if (slug && row.text)
|
|
814
|
+
out.files.set(`inboxes/${slug}/instructions.md`, row.text);
|
|
815
|
+
}
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
async function pullSources(ctx) {
|
|
819
|
+
const { sources } = await read(ctx, "sources list");
|
|
820
|
+
const out = { ...part(), sources };
|
|
821
|
+
const rows = [...sources].sort((a, b) => a.id.localeCompare(b.id));
|
|
822
|
+
const got = await pooled(rows, (row) => call(ctx, "sources get", { source: row.id, export: true }));
|
|
823
|
+
const taken = new Set;
|
|
824
|
+
rows.forEach((row, i) => {
|
|
825
|
+
const one = got[i];
|
|
826
|
+
if (!one || "refused" in one) {
|
|
827
|
+
out.observed.add("Sources with no file yet (website, API and sync sources)", `${row.name} (${row.kind})`);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const base = slugBase(row.name);
|
|
831
|
+
let slug = base;
|
|
832
|
+
for (let n = 2;taken.has(slug); n++)
|
|
833
|
+
slug = `${base}-${n}`;
|
|
834
|
+
taken.add(slug);
|
|
835
|
+
const { id: _id, scope, automation, ...rest } = one.value.file;
|
|
836
|
+
out.files.set(`sources/${slug}.ts`, tsFile([
|
|
837
|
+
`The ${row.name} source: its settings, the names of its secrets (never a value), lanes, scope and tool modes.`,
|
|
838
|
+
"Written by volty pull. Secret values, sign-ins and which tools an admin approved stay in the app."
|
|
839
|
+
], {
|
|
840
|
+
...rest,
|
|
841
|
+
scope: { mode: scope.mode, inboxes: scope.inboxes.map((i) => i.slug), privateInboxes: scope.privateInboxes },
|
|
842
|
+
automation: automation?.slug ?? null
|
|
843
|
+
}));
|
|
844
|
+
});
|
|
845
|
+
return out;
|
|
846
|
+
}
|
|
847
|
+
var notDeployed = (row) => `${row.slug}: ${row.pendingApproval ? "its first version waits for approval" : "no version is running"}`;
|
|
848
|
+
async function pullAutomations(ctx) {
|
|
849
|
+
const out = part();
|
|
850
|
+
const { automations } = await read(ctx, "automations list");
|
|
851
|
+
const got = await pooled(automations, async (row) => {
|
|
852
|
+
if (!row.deployed)
|
|
853
|
+
return null;
|
|
854
|
+
const first = await read(ctx, "automations get", { id: row.id });
|
|
855
|
+
if (first.sourceLabel !== "pending" || !first.active?.version)
|
|
856
|
+
return { got: first, waiting: false };
|
|
857
|
+
return {
|
|
858
|
+
got: await read(ctx, "automations get", { id: row.id, version: first.active.version }),
|
|
859
|
+
waiting: true
|
|
860
|
+
};
|
|
861
|
+
});
|
|
862
|
+
automations.forEach((row, i) => {
|
|
863
|
+
const one = got[i];
|
|
864
|
+
if (!one) {
|
|
865
|
+
out.observed.add("Automations not deployed", notDeployed(row));
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (one.waiting)
|
|
869
|
+
out.observed.add(WAITING_HEADING, `automations/${row.slug}.ts`);
|
|
870
|
+
if (one.got.source !== null)
|
|
871
|
+
out.files.set(`automations/${row.slug}.ts`, one.got.source);
|
|
872
|
+
});
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
async function pullPages(ctx) {
|
|
876
|
+
const out = part();
|
|
877
|
+
const { miniApps } = await read(ctx, "mini-apps list");
|
|
878
|
+
const got = await pooled(miniApps, async (row) => {
|
|
879
|
+
if (!row.deployed)
|
|
880
|
+
return null;
|
|
881
|
+
const first = await read(ctx, "mini-apps get", { id: row.id });
|
|
882
|
+
if (!first.pending)
|
|
883
|
+
return { got: first, waiting: false };
|
|
884
|
+
const active = first.deploys.find((d) => d.status === "active");
|
|
885
|
+
return {
|
|
886
|
+
got: active?.version ? await read(ctx, "mini-apps get", { id: row.id, version: active.version }) : first,
|
|
887
|
+
waiting: true
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
miniApps.forEach((row, i) => {
|
|
891
|
+
const one = got[i];
|
|
892
|
+
if (!one) {
|
|
893
|
+
out.observed.add("Pages not deployed", notDeployed(row));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (one.waiting)
|
|
897
|
+
out.observed.add(WAITING_HEADING, `pages/${row.slug}/`);
|
|
898
|
+
if (one.got.entrySource !== null)
|
|
899
|
+
out.files.set(`pages/${row.slug}/entry.tsx`, one.got.entrySource);
|
|
900
|
+
if (one.got.bffSource !== null)
|
|
901
|
+
out.files.set(`pages/${row.slug}/bff.ts`, one.got.bffSource);
|
|
902
|
+
});
|
|
903
|
+
return out;
|
|
904
|
+
}
|
|
905
|
+
var DRIVE_FOLDER_CAP = 100;
|
|
906
|
+
async function walkDrive(ctx) {
|
|
907
|
+
const files = [];
|
|
908
|
+
let queue = ["/"];
|
|
909
|
+
let walked = 0;
|
|
910
|
+
while (queue.length > 0 && walked < DRIVE_FOLDER_CAP) {
|
|
911
|
+
const level = queue.slice(0, DRIVE_FOLDER_CAP - walked);
|
|
912
|
+
walked += level.length;
|
|
913
|
+
const listed = await pooled(level, (path) => read(ctx, "drive list-folder", { scope: "organization", path }));
|
|
914
|
+
const children = [];
|
|
915
|
+
for (const { entries } of listed) {
|
|
916
|
+
for (const entry of entries) {
|
|
917
|
+
if (entry.kind === "folder")
|
|
918
|
+
children.push(entry.path);
|
|
919
|
+
else
|
|
920
|
+
files.push(entry.path);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
queue = [...queue.slice(level.length), ...children];
|
|
924
|
+
}
|
|
925
|
+
return { files, unread: queue.length };
|
|
926
|
+
}
|
|
927
|
+
async function observeData(ctx) {
|
|
928
|
+
const out = part();
|
|
929
|
+
const [learned, drive, { tables }] = await Promise.all([
|
|
930
|
+
read(ctx, "learned list"),
|
|
931
|
+
walkDrive(ctx),
|
|
932
|
+
read(ctx, "tables list")
|
|
933
|
+
]);
|
|
934
|
+
for (const rule of learned.rules)
|
|
935
|
+
out.observed.add("Learned rules (Volty writes them; a push never touches them)", rule.text);
|
|
936
|
+
for (const path of drive.files)
|
|
937
|
+
out.observed.add(DRIVE_HEADING, path);
|
|
938
|
+
if (drive.unread > 0)
|
|
939
|
+
out.observed.add(DRIVE_HEADING, `${drive.unread} more folders not listed`);
|
|
940
|
+
for (const table of tables)
|
|
941
|
+
out.observed.add("Tables (their rows stay in the workspace)", table.name);
|
|
942
|
+
return out;
|
|
943
|
+
}
|
|
944
|
+
function agentsMd(workspaceId, sources) {
|
|
945
|
+
const systems = [...sources].sort((a, b) => a.name.localeCompare(b.name)).map((s) => `- ${s.name} (${s.kind})`);
|
|
946
|
+
return `# Volty project
|
|
947
|
+
|
|
948
|
+
This folder is one Volty workspace's setup as files, written by \`volty pull\` from the live workspace \`${workspaceId}\`.
|
|
949
|
+
|
|
950
|
+
- \`instructions/org.md\`: the instructions Volty reads everywhere. \`instructions/team.md\`: how Volty works with staff, read only when it talks to staff.
|
|
951
|
+
- \`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.
|
|
952
|
+
- \`sources/<slug>.ts\`: a connected source's settings, the names of its secrets (never a value), lanes and tool modes.
|
|
953
|
+
- \`automations/<slug>.ts\`: an automation's running source.
|
|
954
|
+
- \`pages/<slug>/\`: a page's \`entry.tsx\` and \`bff.ts\`.
|
|
955
|
+
- \`volty.config.ts\`: the workspace this project deploys to.
|
|
956
|
+
- \`.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.
|
|
957
|
+
|
|
958
|
+
Systems connected to this workspace:
|
|
959
|
+
${systems.length > 0 ? systems.join(`
|
|
960
|
+
`) : "- none"}
|
|
961
|
+
|
|
962
|
+
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.
|
|
963
|
+
`;
|
|
964
|
+
}
|
|
965
|
+
function configFile(target, workspaceId) {
|
|
966
|
+
return tsFile([
|
|
967
|
+
"Where this project deploys: each target names one workspace by id.",
|
|
968
|
+
"Written by volty pull; the version each target was pulled from is in .volty/targets."
|
|
969
|
+
], { targets: { [target]: { workspace: workspaceId } } });
|
|
970
|
+
}
|
|
971
|
+
function readTargets(dir) {
|
|
972
|
+
const path = join3(dir, ".volty", "targets");
|
|
973
|
+
if (!existsSync4(path))
|
|
974
|
+
return null;
|
|
975
|
+
return JSON.parse(readFileSync4(path, "utf8"));
|
|
976
|
+
}
|
|
977
|
+
async function pull(ctx, opts) {
|
|
978
|
+
const { dir, workspaceId, target } = opts;
|
|
979
|
+
const targets = readTargets(dir);
|
|
980
|
+
if (targets) {
|
|
981
|
+
const other = Object.entries(targets).find(([, t]) => t.workspace !== workspaceId);
|
|
982
|
+
if (other) {
|
|
983
|
+
throw new Error(`${dir} holds the project of workspace ${other[1].workspace}: pull ${workspaceId} into its own folder.`);
|
|
984
|
+
}
|
|
985
|
+
} else if (existsSync4(dir) && readdirSync(dir).some((name) => !name.startsWith("."))) {
|
|
986
|
+
throw new Error(`${dir} is not empty and no volty pull wrote it: pull into an empty folder.`);
|
|
987
|
+
}
|
|
988
|
+
const inboxes = pullInboxes(ctx);
|
|
989
|
+
const parts = await Promise.all([
|
|
990
|
+
inboxes,
|
|
991
|
+
pullInstructions(ctx, inboxes),
|
|
992
|
+
pullSources(ctx),
|
|
993
|
+
pullAutomations(ctx),
|
|
994
|
+
pullPages(ctx),
|
|
995
|
+
observeData(ctx)
|
|
996
|
+
]);
|
|
997
|
+
const files = new Map;
|
|
998
|
+
const observed = new Observed;
|
|
999
|
+
for (const one of parts) {
|
|
1000
|
+
for (const [rel, text] of one.files)
|
|
1001
|
+
files.set(rel, text);
|
|
1002
|
+
for (const [heading, lines] of one.observed.sections)
|
|
1003
|
+
for (const line of lines)
|
|
1004
|
+
observed.add(heading, line);
|
|
1005
|
+
}
|
|
1006
|
+
files.set("AGENTS.md", agentsMd(workspaceId, parts[2].sources));
|
|
1007
|
+
files.set("volty.config.ts", configFile(target, workspaceId));
|
|
1008
|
+
const base = targets?.[target]?.version;
|
|
1009
|
+
const { baseFiles, ...recorded } = await read(ctx, "project record-pull", {
|
|
1010
|
+
workspace: workspaceId,
|
|
1011
|
+
files: Object.fromEntries(files),
|
|
1012
|
+
...base ? { base } : {}
|
|
1013
|
+
});
|
|
1014
|
+
const written = [];
|
|
1015
|
+
const deleted = [];
|
|
1016
|
+
const kept = [];
|
|
1017
|
+
const edited = (rel, local) => base !== undefined && sha256(local) !== baseFiles[rel];
|
|
1018
|
+
for (const [rel, text] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1019
|
+
const path = join3(dir, rel);
|
|
1020
|
+
const local = existsSync4(path) ? readFileSync4(path, "utf8") : null;
|
|
1021
|
+
if (local === text)
|
|
1022
|
+
continue;
|
|
1023
|
+
if (local !== null && edited(rel, local)) {
|
|
1024
|
+
kept.push({ path: rel, workspace: sha256(text) === baseFiles[rel] ? "unchanged" : "changed" });
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
mkdirSync3(dirname2(path), { recursive: true });
|
|
1028
|
+
writeFileSync4(path, text);
|
|
1029
|
+
written.push(rel);
|
|
1030
|
+
}
|
|
1031
|
+
for (const rel of Object.keys(baseFiles).sort()) {
|
|
1032
|
+
const path = join3(dir, rel);
|
|
1033
|
+
if (files.has(rel) || !existsSync4(path))
|
|
1034
|
+
continue;
|
|
1035
|
+
if (edited(rel, readFileSync4(path, "utf8"))) {
|
|
1036
|
+
kept.push({ path: rel, workspace: "removed" });
|
|
1037
|
+
} else {
|
|
1038
|
+
unlinkSync(path);
|
|
1039
|
+
deleted.push(rel);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
mkdirSync3(join3(dir, ".volty"), { recursive: true });
|
|
1043
|
+
const nextTargets = {
|
|
1044
|
+
...targets,
|
|
1045
|
+
[target]: { workspace: recorded.workspaceId, version: recorded.versionId }
|
|
1046
|
+
};
|
|
1047
|
+
writeFileSync4(join3(dir, ".volty", "targets"), `${JSON.stringify(nextTargets, null, 2)}
|
|
1048
|
+
`);
|
|
1049
|
+
const observedPath = join3(dir, ".volty", "observed");
|
|
1050
|
+
if (existsSync4(observedPath))
|
|
1051
|
+
chmodSync(observedPath, 420);
|
|
1052
|
+
writeFileSync4(observedPath, observed.render());
|
|
1053
|
+
chmodSync(observedPath, 292);
|
|
1054
|
+
return { ...recorded, written, deleted, kept };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
649
1057
|
// src/update.ts
|
|
650
|
-
import { mkdirSync as
|
|
1058
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
651
1059
|
import { homedir as homedir2 } from "node:os";
|
|
652
|
-
import { dirname as
|
|
653
|
-
var CACHE_PATH =
|
|
1060
|
+
import { dirname as dirname3, join as join4 } from "node:path";
|
|
1061
|
+
var CACHE_PATH = join4(homedir2(), ".config", "volty", "update-check.json");
|
|
654
1062
|
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
655
1063
|
var REGISTRY_LATEST = "https://registry.npmjs.org/@voltade/volty/latest";
|
|
656
1064
|
function currentVersion() {
|
|
657
1065
|
try {
|
|
658
|
-
const pkg = JSON.parse(
|
|
1066
|
+
const pkg = JSON.parse(readFileSync5(join4(import.meta.dirname, "..", "package.json"), "utf8"));
|
|
659
1067
|
return pkg.version ?? null;
|
|
660
1068
|
} catch {
|
|
661
1069
|
return null;
|
|
@@ -673,15 +1081,15 @@ function cmpVersion(a, b) {
|
|
|
673
1081
|
}
|
|
674
1082
|
function readCache() {
|
|
675
1083
|
try {
|
|
676
|
-
return JSON.parse(
|
|
1084
|
+
return JSON.parse(readFileSync5(CACHE_PATH, "utf8"));
|
|
677
1085
|
} catch {
|
|
678
1086
|
return null;
|
|
679
1087
|
}
|
|
680
1088
|
}
|
|
681
1089
|
function writeCache(cache) {
|
|
682
1090
|
try {
|
|
683
|
-
|
|
684
|
-
|
|
1091
|
+
mkdirSync4(dirname3(CACHE_PATH), { recursive: true });
|
|
1092
|
+
writeFileSync5(CACHE_PATH, `${JSON.stringify(cache)}
|
|
685
1093
|
`);
|
|
686
1094
|
} catch {}
|
|
687
1095
|
}
|
|
@@ -903,6 +1311,41 @@ async function handleChanges(rest, ctx) {
|
|
|
903
1311
|
process.stdout.write(`${dec.value === "approve" ? "Approved" : "Rejected"} proposal ${res.body.proposal?.id ?? pid.value} → ${res.body.proposal?.status ?? "decided"}
|
|
904
1312
|
`);
|
|
905
1313
|
}
|
|
1314
|
+
var KEPT_NOTE = {
|
|
1315
|
+
unchanged: "",
|
|
1316
|
+
changed: ", and the workspace changed it too: delete it and pull again for the workspace copy",
|
|
1317
|
+
removed: ", and the workspace no longer has it"
|
|
1318
|
+
};
|
|
1319
|
+
async function handlePull(rest, ctx, env) {
|
|
1320
|
+
const [dir = ".", ...extra] = rest;
|
|
1321
|
+
if (extra.length > 0 || dir.startsWith("--"))
|
|
1322
|
+
fail("usage: volty pull [dir] [--workspace <id>]");
|
|
1323
|
+
let workspaceId = ctx.organizationId;
|
|
1324
|
+
if (!workspaceId) {
|
|
1325
|
+
const res = await whoami(ctx);
|
|
1326
|
+
if ("error" in res)
|
|
1327
|
+
fail(res.error);
|
|
1328
|
+
workspaceId = res.who.organizationId;
|
|
1329
|
+
}
|
|
1330
|
+
if (!workspaceId)
|
|
1331
|
+
fail("this key names no workspace: run `volty auth login`");
|
|
1332
|
+
const out = await pull(ctx, { dir, workspaceId, target: env });
|
|
1333
|
+
if (out.unchanged && out.written.length + out.deleted.length + out.kept.length === 0) {
|
|
1334
|
+
process.stdout.write(`${dir} is up to date with version ${out.versionId} of workspace ${out.workspaceId}.
|
|
1335
|
+
`);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const lines = [
|
|
1339
|
+
`Pulled workspace ${out.workspaceId} into ${dir}: version ${out.versionId}${out.unchanged ? " (unchanged)" : ""}.`,
|
|
1340
|
+
...out.written.map((f) => ` wrote ${f}`),
|
|
1341
|
+
...out.deleted.map((f) => ` deleted ${f}, which the workspace no longer has`),
|
|
1342
|
+
...out.kept.map((f) => ` kept ${f.path}: edited here${KEPT_NOTE[f.workspace]}`),
|
|
1343
|
+
"What the project does not hold is listed in .volty/observed."
|
|
1344
|
+
];
|
|
1345
|
+
process.stdout.write(`${lines.join(`
|
|
1346
|
+
`)}
|
|
1347
|
+
`);
|
|
1348
|
+
}
|
|
906
1349
|
async function handleDocs(config, env) {
|
|
907
1350
|
const ctx = ensureContext(config, env);
|
|
908
1351
|
if (!ctx.baseUrl)
|
|
@@ -916,16 +1359,16 @@ function handleSkill(rest) {
|
|
|
916
1359
|
if (rest[0] !== "install")
|
|
917
1360
|
fail("usage: volty skill install");
|
|
918
1361
|
const candidates = [
|
|
919
|
-
|
|
920
|
-
|
|
1362
|
+
join5(import.meta.dirname, "..", "skill", "volty-cli-ops", "SKILL.md"),
|
|
1363
|
+
join5(import.meta.dirname, "..", "..", ".claude", "skills", "volty-cli-ops", "SKILL.md")
|
|
921
1364
|
];
|
|
922
|
-
const source = candidates.find((p) =>
|
|
1365
|
+
const source = candidates.find((p) => existsSync5(p));
|
|
923
1366
|
if (!source)
|
|
924
1367
|
fail("bundled SKILL.md not found — reinstall the @voltade/volty package");
|
|
925
|
-
const destDir =
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
process.stdout.write(`Installed the volty-cli-ops skill → ${
|
|
1368
|
+
const destDir = join5(homedir3(), ".claude", "skills", "volty-cli-ops");
|
|
1369
|
+
mkdirSync5(destDir, { recursive: true });
|
|
1370
|
+
writeFileSync6(join5(destDir, "SKILL.md"), readFileSync6(source, "utf8"));
|
|
1371
|
+
process.stdout.write(`Installed the volty-cli-ops skill → ${join5(destDir, "SKILL.md")}
|
|
929
1372
|
`);
|
|
930
1373
|
}
|
|
931
1374
|
async function main() {
|
|
@@ -936,6 +1379,8 @@ async function main() {
|
|
|
936
1379
|
argv = orgExtract.rest;
|
|
937
1380
|
const cacheFlag = takeBool(argv, "--no-cache");
|
|
938
1381
|
argv = cacheFlag.rest;
|
|
1382
|
+
const workspaceExtract = argv[0] === "pull" ? takeValue(argv, "--workspace") : { value: undefined, rest: argv };
|
|
1383
|
+
argv = workspaceExtract.rest;
|
|
939
1384
|
const config = loadConfig();
|
|
940
1385
|
const env = activeEnv(config, envExtract.value ?? (process.env.VOLTY_ENV || undefined));
|
|
941
1386
|
const peek = config.contexts[env];
|
|
@@ -943,7 +1388,7 @@ async function main() {
|
|
|
943
1388
|
const target = resolveTargetOrg({
|
|
944
1389
|
env,
|
|
945
1390
|
baseUrl: peek?.baseUrl,
|
|
946
|
-
override: orgExtract.value ?? (process.env.VOLTY_ORG || undefined),
|
|
1391
|
+
override: workspaceExtract.value ?? orgExtract.value ?? (process.env.VOLTY_ORG || undefined),
|
|
947
1392
|
sticky: peek?.activeOrg,
|
|
948
1393
|
command: argv
|
|
949
1394
|
});
|
|
@@ -984,6 +1429,8 @@ async function main() {
|
|
|
984
1429
|
process.stderr.write(`→ ${env} · ${res.who.organizationName} (${res.who.organizationId}) · ${res.who.role}
|
|
985
1430
|
`);
|
|
986
1431
|
}
|
|
1432
|
+
if (cmd === "pull")
|
|
1433
|
+
return handlePull(rest, ctx, env);
|
|
987
1434
|
if (cmd === "changes" && rest[0] === "decide")
|
|
988
1435
|
return handleChanges(rest, ctx);
|
|
989
1436
|
if (cmd === "keys")
|
package/package.json
CHANGED