@overscore/cli 0.13.20 → 0.13.21
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/index.js +176 -17
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -911,9 +911,125 @@ function formatCell(value) {
|
|
|
911
911
|
return value.toLocaleString();
|
|
912
912
|
return String(value);
|
|
913
913
|
}
|
|
914
|
+
// ── Pull safety: manifest + local-edit detection + backup ───────────────
|
|
915
|
+
// Each pull records a manifest of the files it wrote, so a re-pull can tell whether
|
|
916
|
+
// the folder has un-pulled local edits before it overwrites them. Without this,
|
|
917
|
+
// `pull --force` silently destroys work that isn't in the Hub yet.
|
|
918
|
+
const PULL_MANIFEST = ".overscore-pull.json";
|
|
919
|
+
// Files we never track/hash: dependencies, secrets, VCS metadata, and the manifest itself.
|
|
920
|
+
function isTrackedPullEntry(rel) {
|
|
921
|
+
const top = rel.split(path.sep)[0];
|
|
922
|
+
if (top === "node_modules" || top === ".git")
|
|
923
|
+
return false;
|
|
924
|
+
if (top.startsWith(".env"))
|
|
925
|
+
return false;
|
|
926
|
+
if (rel === PULL_MANIFEST)
|
|
927
|
+
return false;
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
function listPulledFiles(dir) {
|
|
931
|
+
const out = [];
|
|
932
|
+
const walk = (abs, rel) => {
|
|
933
|
+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
934
|
+
const childRel = rel ? path.join(rel, entry.name) : entry.name;
|
|
935
|
+
if (!isTrackedPullEntry(childRel))
|
|
936
|
+
continue;
|
|
937
|
+
const childAbs = path.join(abs, entry.name);
|
|
938
|
+
if (entry.isDirectory())
|
|
939
|
+
walk(childAbs, childRel);
|
|
940
|
+
else if (entry.isFile())
|
|
941
|
+
out.push(childRel);
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
walk(dir, "");
|
|
945
|
+
return out;
|
|
946
|
+
}
|
|
947
|
+
function hashPulledFile(abs) {
|
|
948
|
+
return createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
|
|
949
|
+
}
|
|
950
|
+
function writePullManifest(dir, version) {
|
|
951
|
+
const files = {};
|
|
952
|
+
for (const rel of listPulledFiles(dir)) {
|
|
953
|
+
try {
|
|
954
|
+
files[rel] = hashPulledFile(path.join(dir, rel));
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
/* skip unreadable */
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
fs.writeFileSync(path.join(dir, PULL_MANIFEST), JSON.stringify({ version, pulled_at: new Date().toISOString(), files }, null, 2));
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
/* non-fatal: worst case, the next pull treats the folder as having local edits */
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// True if the folder has edits relative to its last pull — or if we can't tell (no
|
|
968
|
+
// manifest), in which case we err on the side of preserving whatever's there.
|
|
969
|
+
function hasLocalPullEdits(dir) {
|
|
970
|
+
let manifest;
|
|
971
|
+
try {
|
|
972
|
+
manifest = JSON.parse(fs.readFileSync(path.join(dir, PULL_MANIFEST), "utf-8"));
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
return true; // no/unreadable manifest — can't verify, treat as having local edits
|
|
976
|
+
}
|
|
977
|
+
const recorded = manifest.files || {};
|
|
978
|
+
// A tracked file the last pull didn't write is a local addition.
|
|
979
|
+
for (const rel of listPulledFiles(dir)) {
|
|
980
|
+
if (!(rel in recorded))
|
|
981
|
+
return true;
|
|
982
|
+
}
|
|
983
|
+
// A recorded file that's now missing or changed is a local edit.
|
|
984
|
+
for (const rel of Object.keys(recorded)) {
|
|
985
|
+
const abs = path.join(dir, rel);
|
|
986
|
+
if (!fs.existsSync(abs))
|
|
987
|
+
return true;
|
|
988
|
+
try {
|
|
989
|
+
if (hashPulledFile(abs) !== recorded[rel])
|
|
990
|
+
return true;
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
// Copy the current source (minus node_modules/.git) to a sibling backup folder so a
|
|
999
|
+
// forced re-pull never destroys un-pulled work. Returns the backup path.
|
|
1000
|
+
function backupPulledSource(dir) {
|
|
1001
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1002
|
+
let backup = `${dir}.local-backup-${stamp}`;
|
|
1003
|
+
let n = 1;
|
|
1004
|
+
while (fs.existsSync(backup))
|
|
1005
|
+
backup = `${dir}.local-backup-${stamp}-${n++}`;
|
|
1006
|
+
fs.cpSync(dir, backup, {
|
|
1007
|
+
recursive: true,
|
|
1008
|
+
filter: (src) => {
|
|
1009
|
+
const rel = path.relative(dir, src);
|
|
1010
|
+
if (!rel)
|
|
1011
|
+
return true;
|
|
1012
|
+
// Keep everything except dependencies, VCS, and the manifest; do keep .env* so the backup is complete.
|
|
1013
|
+
const top = rel.split(path.sep)[0];
|
|
1014
|
+
if (top === "node_modules" || top === ".git" || rel === PULL_MANIFEST)
|
|
1015
|
+
return false;
|
|
1016
|
+
return true;
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
return backup;
|
|
1020
|
+
}
|
|
1021
|
+
async function promptPullConflict() {
|
|
1022
|
+
const answer = await prompt("This folder has local changes that aren't deployed. [K]eep mine / [r]eplace / [b]ackup then replace? (K/r/b)");
|
|
1023
|
+
const a = answer.trim().toLowerCase();
|
|
1024
|
+
if (a === "r" || a === "replace")
|
|
1025
|
+
return "replace";
|
|
1026
|
+
if (a === "b" || a === "backup")
|
|
1027
|
+
return "backup";
|
|
1028
|
+
return "keep";
|
|
1029
|
+
}
|
|
914
1030
|
async function pull(slug) {
|
|
915
1031
|
if (!slug) {
|
|
916
|
-
console.error("\n Usage: npx @overscore/cli pull <dashboard-slug> [--version N] [--force]\n");
|
|
1032
|
+
console.error("\n Usage: npx @overscore/cli pull <dashboard-slug> [--project <slug>] [--version N] [--force]\n");
|
|
917
1033
|
console.error(" Example:");
|
|
918
1034
|
console.error(" npx @overscore/cli pull my-dashboard\n");
|
|
919
1035
|
process.exit(1);
|
|
@@ -923,10 +1039,18 @@ async function pull(slug) {
|
|
|
923
1039
|
const versionParam = versionIndex !== -1 && process.argv[versionIndex + 1]
|
|
924
1040
|
? process.argv[versionIndex + 1]
|
|
925
1041
|
: null;
|
|
926
|
-
// --force skips the
|
|
927
|
-
//
|
|
928
|
-
//
|
|
1042
|
+
// --force skips the interactive keep/replace/backup prompt for non-interactive re-pulls
|
|
1043
|
+
// (scripted rebuilds / `workspace sync`). It is NOT a license to destroy: if the folder has
|
|
1044
|
+
// un-pulled local edits, the pull backs them up before overwriting (see below).
|
|
929
1045
|
const force = process.argv.includes("--force");
|
|
1046
|
+
// --project scopes the api-key mint to the right project. Optional: the dashboard slug is
|
|
1047
|
+
// used as a fallback, correct only when a project has a single same-named dashboard.
|
|
1048
|
+
const projectIndex = process.argv.indexOf("--project");
|
|
1049
|
+
const projectFlag = projectIndex !== -1 &&
|
|
1050
|
+
process.argv[projectIndex + 1] &&
|
|
1051
|
+
!process.argv[projectIndex + 1].startsWith("--")
|
|
1052
|
+
? process.argv[projectIndex + 1]
|
|
1053
|
+
: null;
|
|
930
1054
|
// Resolve API key: device token → auto-create os_ key, else the saved project key, else prompt.
|
|
931
1055
|
let apiKey = "";
|
|
932
1056
|
const configPath = path.join(process.env.HOME || "", ".overscore", "config");
|
|
@@ -935,14 +1059,18 @@ async function pull(slug) {
|
|
|
935
1059
|
: {};
|
|
936
1060
|
const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
|
|
937
1061
|
if (deviceToken) {
|
|
1062
|
+
// Prefer the explicit --project slug; fall back to the dashboard slug (correct only when
|
|
1063
|
+
// a project has a single same-named dashboard). A wrong project slug 404s and leaves
|
|
1064
|
+
// apiKey empty, which we handle explicitly below.
|
|
1065
|
+
const keyProjectSlug = projectFlag || slug;
|
|
938
1066
|
try {
|
|
939
|
-
const keyRes = await fetch(`${HUB_URL}/api/projects/${
|
|
1067
|
+
const keyRes = await fetch(`${HUB_URL}/api/projects/${keyProjectSlug}/api-keys`, {
|
|
940
1068
|
method: "POST",
|
|
941
1069
|
headers: {
|
|
942
1070
|
Authorization: `Bearer ${deviceToken}`,
|
|
943
1071
|
"Content-Type": "application/json",
|
|
944
1072
|
},
|
|
945
|
-
body: JSON.stringify({ name: `${
|
|
1073
|
+
body: JSON.stringify({ name: `${keyProjectSlug} pull key` }),
|
|
946
1074
|
});
|
|
947
1075
|
if (keyRes.status === 401 || keyRes.status === 403) {
|
|
948
1076
|
console.error("\n Error: Device token is invalid or expired. Run 'npx @overscore/cli auth login' to re-authenticate.\n");
|
|
@@ -957,14 +1085,26 @@ async function pull(slug) {
|
|
|
957
1085
|
// Network error — fall through to the saved key / prompt
|
|
958
1086
|
}
|
|
959
1087
|
}
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
// 404s and leaves apiKey empty. Fall back to the project API key saved by `auth login`:
|
|
963
|
-
// it authorizes every dashboard in the logged-in project, which is the common pull case
|
|
964
|
-
// (and what makes non-interactive `pull` / scripted rebuilds work).
|
|
1088
|
+
// Fall back to the project API key saved by `auth login`, if any: it authorizes every
|
|
1089
|
+
// dashboard in the logged-in project.
|
|
965
1090
|
if (!apiKey && cfg.OVERSCORE_API_KEY) {
|
|
966
1091
|
apiKey = cfg.OVERSCORE_API_KEY;
|
|
967
1092
|
}
|
|
1093
|
+
// A logged-in (device-token) user whose mint failed and who has no saved project key gets a
|
|
1094
|
+
// clear, actionable error instead of a raw "paste an API key" prompt they have no key for.
|
|
1095
|
+
// The interactive prompt below is reserved for the pure API-key path (no device token).
|
|
1096
|
+
if (!apiKey && deviceToken) {
|
|
1097
|
+
if (!projectFlag) {
|
|
1098
|
+
console.error(`\n Error: Couldn't resolve the project for dashboard "${slug}".`);
|
|
1099
|
+
console.error(` If it belongs to a multi-dashboard project, pass the project slug:`);
|
|
1100
|
+
console.error(` npx @overscore/cli pull ${slug} --project <project-slug>\n`);
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
console.error(`\n Error: Couldn't create a key for project "${projectFlag}".`);
|
|
1104
|
+
console.error(` Check the project slug and that you have access to it.\n`);
|
|
1105
|
+
}
|
|
1106
|
+
process.exit(1);
|
|
1107
|
+
}
|
|
968
1108
|
if (!apiKey) {
|
|
969
1109
|
console.log(" Tip: Run 'npx @overscore/cli auth login' to authenticate once and skip this step.");
|
|
970
1110
|
apiKey = await prompt("API key (from the Hub):");
|
|
@@ -1001,15 +1141,31 @@ async function pull(slug) {
|
|
|
1001
1141
|
const zipBuffer = Buffer.from(await res.arrayBuffer());
|
|
1002
1142
|
// Extract to ./<slug>/
|
|
1003
1143
|
const targetDir = path.resolve(process.cwd(), slug);
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1144
|
+
const dirExists = fs.existsSync(targetDir);
|
|
1145
|
+
// Guard against destroying un-pulled local edits before we overwrite the folder.
|
|
1146
|
+
if (dirExists && hasLocalPullEdits(targetDir)) {
|
|
1147
|
+
if (force) {
|
|
1148
|
+
// Non-interactive re-pull (workspace sync / scripts): never destroy silently —
|
|
1149
|
+
// snapshot the local edits first, then proceed.
|
|
1150
|
+
const backup = backupPulledSource(targetDir);
|
|
1151
|
+
console.log(`\n Local changes detected — backed them up to:\n ${backup}`);
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
const choice = await promptPullConflict();
|
|
1155
|
+
if (choice === "keep") {
|
|
1156
|
+
console.log(" Cancelled — your local files are untouched.\n");
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (choice === "backup") {
|
|
1160
|
+
const backup = backupPulledSource(targetDir);
|
|
1161
|
+
console.log(` Backed up your changes to:\n ${backup}`);
|
|
1162
|
+
}
|
|
1163
|
+
// "replace" → fall through to the overwrite
|
|
1009
1164
|
}
|
|
1010
1165
|
}
|
|
1166
|
+
// A clean folder (matches its last pull) is safe to overwrite with no prompt.
|
|
1011
1167
|
// Clean target directory (preserve node_modules and .env)
|
|
1012
|
-
if (
|
|
1168
|
+
if (dirExists) {
|
|
1013
1169
|
for (const entry of fs.readdirSync(targetDir)) {
|
|
1014
1170
|
if (entry === "node_modules" || entry.startsWith(".env"))
|
|
1015
1171
|
continue;
|
|
@@ -1077,6 +1233,9 @@ VITE_OVERSCORE_API_URL=${HUB_URL}/api
|
|
|
1077
1233
|
}
|
|
1078
1234
|
// Pull latest platform rules (includes save-knowledge skill)
|
|
1079
1235
|
await syncPlatformRules(targetDir, baseUrl, apiKey);
|
|
1236
|
+
// Record what this pull wrote so a future re-pull can detect local edits before
|
|
1237
|
+
// overwriting them (see hasLocalPullEdits / the --force backup above).
|
|
1238
|
+
writePullManifest(targetDir, version);
|
|
1080
1239
|
// Print context summary so the developer knows what Claude has access to
|
|
1081
1240
|
const knowledgeDir = path.join(targetDir, ".claude", "knowledge");
|
|
1082
1241
|
const knowledgeFiles = fs.existsSync(knowledgeDir)
|