create-egregore 0.21.0-runtime-mvp.22 → 0.21.0-runtime-mvp.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/assets/egregore-launcher.js +30 -42
- package/lib/setup.js +53 -16
- package/package.json +1 -1
- package/runtime/claude/bin/runtime-identity.sh +40 -11
- package/runtime/claude/bin/runtime-upgrade.sh +1 -1
- package/runtime/claude/egregore_runtime/identity_cli.py +55 -0
- package/runtime/codex/bin/runtime-identity.sh +40 -11
- package/runtime/codex/bin/runtime-upgrade.sh +1 -1
- package/runtime/codex/egregore_runtime/identity_cli.py +55 -0
- package/runtime/pi/bin/runtime-identity.sh +40 -11
- package/runtime/pi/bin/runtime-upgrade.sh +1 -1
- package/runtime/pi/egregore_runtime/identity_cli.py +55 -0
- package/runtime/prime/bin/runtime-identity.sh +40 -11
- package/runtime/prime/bin/runtime-upgrade.sh +1 -1
- package/runtime/prime/egregore_runtime/identity_cli.py +55 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// egregore-launcher-version: 0.21.0-runtime-mvp.
|
|
2
|
+
// egregore-launcher-version: 0.21.0-runtime-mvp.24
|
|
3
3
|
//
|
|
4
4
|
// egregore — launch and browse Egregore instances from your terminal.
|
|
5
5
|
// Installed by create-egregore. Reads ~/.egregore/instances.json.
|
|
@@ -35,7 +35,7 @@ const crypto = require("node:crypto");
|
|
|
35
35
|
const { spawnSync, spawn } = require("node:child_process");
|
|
36
36
|
const readline = require("node:readline");
|
|
37
37
|
|
|
38
|
-
const VERSION = "0.21.0-runtime-mvp.
|
|
38
|
+
const VERSION = "0.21.0-runtime-mvp.24";
|
|
39
39
|
const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
|
|
40
40
|
const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
|
|
41
41
|
const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
|
|
@@ -285,7 +285,23 @@ function readRegistry() {
|
|
|
285
285
|
if (key.includes("/.claude/worktrees/")) continue;
|
|
286
286
|
if (seen.has(key)) continue;
|
|
287
287
|
seen.add(key);
|
|
288
|
-
|
|
288
|
+
// The checkout config is the canonical identity for a path. Older
|
|
289
|
+
// installers could overwrite registry labels when two organizations
|
|
290
|
+
// used the same repository/folder name, producing hybrid rows such as
|
|
291
|
+
// another org's slug beside the Curve Labs checkout.
|
|
292
|
+
let canonical = e;
|
|
293
|
+
try {
|
|
294
|
+
const config = JSON.parse(
|
|
295
|
+
fs.readFileSync(path.join(key, "egregore.json"), "utf-8")
|
|
296
|
+
);
|
|
297
|
+
canonical = {
|
|
298
|
+
...e,
|
|
299
|
+
slug: config.slug || e.slug,
|
|
300
|
+
name: config.org_name || e.name,
|
|
301
|
+
};
|
|
302
|
+
if (config.org_id) canonical.org_id = config.org_id;
|
|
303
|
+
} catch {}
|
|
304
|
+
result.push(canonical);
|
|
289
305
|
}
|
|
290
306
|
return result;
|
|
291
307
|
} catch {
|
|
@@ -750,9 +766,8 @@ function codexSupportMessage(egregoreDir) {
|
|
|
750
766
|
" .codex/hooks.json",
|
|
751
767
|
" .codex/hooks/",
|
|
752
768
|
"",
|
|
753
|
-
"
|
|
754
|
-
"
|
|
755
|
-
"Then rerun egregore.",
|
|
769
|
+
"Restore this instance using its installed runtime version, then rerun egregore.",
|
|
770
|
+
"A global CLI refresh does not repair instance files.",
|
|
756
771
|
"Egregore workflows appear as slash commands. In Codex, invoke them with the matching $skill token or ask in natural language.",
|
|
757
772
|
].join("\n");
|
|
758
773
|
}
|
|
@@ -783,7 +798,7 @@ function piSupportMessage(egregoreDir) {
|
|
|
783
798
|
"Pi meta-harness support is missing from this Egregore checkout.",
|
|
784
799
|
` path: ${egregoreDir}`,
|
|
785
800
|
"",
|
|
786
|
-
"
|
|
801
|
+
"Restore this instance using its installed runtime version, then rerun egregore.",
|
|
787
802
|
].join("\n");
|
|
788
803
|
}
|
|
789
804
|
|
|
@@ -802,31 +817,10 @@ function primeSupportMessage(egregoreDir) {
|
|
|
802
817
|
"Prime Agent runtime support is missing from this Egregore checkout.",
|
|
803
818
|
` path: ${egregoreDir}`,
|
|
804
819
|
"",
|
|
805
|
-
"
|
|
820
|
+
"Restore this instance using its installed runtime version, then rerun egregore.",
|
|
806
821
|
].join("\n");
|
|
807
822
|
}
|
|
808
823
|
|
|
809
|
-
function repairPortableRuntime(egregoreDir, runtime) {
|
|
810
|
-
const label = runtimeLabel(runtime);
|
|
811
|
-
process.stdout.write(`\n ${CYAN}↻${RESET} ${BOLD}Repairing ${label} support${RESET}${DIM} — updating this checkout${RESET}\n`);
|
|
812
|
-
const result = spawnSync(
|
|
813
|
-
"npx",
|
|
814
|
-
["-y", `${PUBLIC_NPX_PACKAGE}@latest`, "--refresh-launcher"],
|
|
815
|
-
{
|
|
816
|
-
cwd: egregoreDir,
|
|
817
|
-
stdio: "inherit",
|
|
818
|
-
env: { ...process.env, EGREGORE_RUNTIME_REPAIR: runtime },
|
|
819
|
-
timeout: 3 * 60 * 1000,
|
|
820
|
-
},
|
|
821
|
-
);
|
|
822
|
-
if (result.error || result.status !== 0) {
|
|
823
|
-
process.stderr.write(` ${YELLOW}!${RESET} Automatic ${label} repair failed; the checkout was left in place.\n`);
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
process.stdout.write(` ${GREEN}✓${RESET} ${label} support repaired\n\n`);
|
|
827
|
-
return true;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
824
|
// Minimal y/N line prompt for pre-launch decisions (the TUI select machinery
|
|
831
825
|
// is torn down by the time launch() runs).
|
|
832
826
|
function confirmLine(question) {
|
|
@@ -972,19 +966,15 @@ async function launch(inst, runtimeOverride) {
|
|
|
972
966
|
}
|
|
973
967
|
}
|
|
974
968
|
if (runtime === "codex") {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
die(codexSupportMessage(inst.path));
|
|
978
|
-
}
|
|
979
|
-
}
|
|
969
|
+
// Launching never changes an instance's runtime or release channel.
|
|
970
|
+
if (!hasCodexCoreSupport(inst.path)) die(codexSupportMessage(inst.path));
|
|
980
971
|
const skillsInstalled = installCodexSkills(inst.path);
|
|
981
972
|
const cardRendered = await renderCodexStartupCard(inst.path);
|
|
982
973
|
if (!skillsInstalled || !cardRendered) {
|
|
983
974
|
die(codexSupportMessage(inst.path));
|
|
984
975
|
}
|
|
985
976
|
} else if (runtime === "pi") {
|
|
986
|
-
if (!hasPiCoreSupport(inst.path)
|
|
987
|
-
(!repairPortableRuntime(inst.path, runtime) || !hasPiCoreSupport(inst.path))) {
|
|
977
|
+
if (!hasPiCoreSupport(inst.path)) {
|
|
988
978
|
die(piSupportMessage(inst.path));
|
|
989
979
|
}
|
|
990
980
|
}
|
|
@@ -995,10 +985,7 @@ async function launch(inst, runtimeOverride) {
|
|
|
995
985
|
// check + the trust-flag probe, which shells out to the binary), then a
|
|
996
986
|
// single handoff line explains the remaining in-harness startup.
|
|
997
987
|
const stopLoader = startSpinner("Starting Prime Agent");
|
|
998
|
-
|
|
999
|
-
if (!coreOk && repairPortableRuntime(inst.path, runtime)) {
|
|
1000
|
-
coreOk = hasPrimeCoreSupport(inst.path);
|
|
1001
|
-
}
|
|
988
|
+
const coreOk = hasPrimeCoreSupport(inst.path);
|
|
1002
989
|
launchArgs = coreOk ? runtimeLaunchArgs(runtime, inst.path) : null;
|
|
1003
990
|
stopLoader();
|
|
1004
991
|
if (!coreOk) die(primeSupportMessage(inst.path));
|
|
@@ -1867,9 +1854,10 @@ async function cmdConnect(args) {
|
|
|
1867
1854
|
console.log(` ${DIM}${line}${RESET}`);
|
|
1868
1855
|
}
|
|
1869
1856
|
}
|
|
1857
|
+
// Connect is an instance-scoped overlay; never refresh other runtimes here.
|
|
1870
1858
|
const runtimeRefresh = spawnSync(
|
|
1871
1859
|
"npx",
|
|
1872
|
-
["-y", `${PUBLIC_NPX_PACKAGE}
|
|
1860
|
+
["-y", `${PUBLIC_NPX_PACKAGE}@${VERSION}`, "--refresh-connect"],
|
|
1873
1861
|
{
|
|
1874
1862
|
cwd: inst.path,
|
|
1875
1863
|
encoding: "utf-8",
|
|
@@ -1881,7 +1869,7 @@ async function cmdConnect(args) {
|
|
|
1881
1869
|
console.log(` ${GREEN}✓${RESET} Connect features installed`);
|
|
1882
1870
|
} else {
|
|
1883
1871
|
console.log(` ${YELLOW}!${RESET} Connect features will retry on the next update`);
|
|
1884
|
-
console.log(` ${DIM}npx -y ${PUBLIC_NPX_PACKAGE}
|
|
1872
|
+
console.log(` ${DIM}npx -y ${PUBLIC_NPX_PACKAGE}@${VERSION} --refresh-connect${RESET}`);
|
|
1885
1873
|
}
|
|
1886
1874
|
console.log("");
|
|
1887
1875
|
console.log(` ${GREEN}✓${RESET} ${BOLD}${inst.slug}${RESET} is connected. Local files remain the source of truth.`);
|
package/lib/setup.js
CHANGED
|
@@ -219,17 +219,37 @@ function slugifyName(name) {
|
|
|
219
219
|
*
|
|
220
220
|
* Most Egregore forks are named "egregore", so deriving the target only from
|
|
221
221
|
* the fork basename makes different organizations collide in the same parent
|
|
222
|
-
* directory. If that default path
|
|
223
|
-
* origin, leave it untouched and use egregore-{org-slug}. The fallback
|
|
224
|
-
* also be absent or
|
|
225
|
-
* guessing at another path.
|
|
222
|
+
* directory. If that default path already belongs to another configured org
|
|
223
|
+
* or Git origin, leave it untouched and use egregore-{org-slug}. The fallback
|
|
224
|
+
* must also be absent or belong to the requested org and fork; otherwise stop
|
|
225
|
+
* rather than guessing at another path.
|
|
226
226
|
*/
|
|
227
|
-
function
|
|
227
|
+
function configuredIdentityConflicts(dir, slug, orgId = "") {
|
|
228
|
+
let config;
|
|
229
|
+
try {
|
|
230
|
+
config = JSON.parse(fs.readFileSync(path.join(dir, "egregore.json"), "utf-8"));
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
const existingSlug = slugifyName(config.slug || "");
|
|
235
|
+
const requestedSlug = slugifyName(slug || "");
|
|
236
|
+
if (existingSlug && requestedSlug && existingSlug !== requestedSlug) return true;
|
|
237
|
+
|
|
238
|
+
const stable = (value) => typeof value === "string" && value && !value.startsWith("legacy-org:");
|
|
239
|
+
return stable(config.org_id) && stable(orgId) && config.org_id !== orgId;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function installTargetMatches(dir, forkUrl, slug, orgId = "") {
|
|
243
|
+
if (configuredIdentityConflicts(dir, slug, orgId)) return false;
|
|
244
|
+
const gitPath = path.join(dir, ".git");
|
|
245
|
+
return !fs.existsSync(gitPath) || gitRemoteMatches(dir, forkUrl);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveEgregoreInstallDir(base, forkUrl, slug, orgId = "") {
|
|
228
249
|
const forkDirName = forkUrl.split("/").pop().replace(/\.git$/, "");
|
|
229
250
|
const requestedDir = path.join(base, forkDirName);
|
|
230
|
-
const requestedGitPath = path.join(requestedDir, ".git");
|
|
231
251
|
|
|
232
|
-
if (
|
|
252
|
+
if (installTargetMatches(requestedDir, forkUrl, slug, orgId)) {
|
|
233
253
|
return { egregoreDir: requestedDir, redirectedFrom: null };
|
|
234
254
|
}
|
|
235
255
|
|
|
@@ -237,15 +257,14 @@ function resolveEgregoreInstallDir(base, forkUrl, slug) {
|
|
|
237
257
|
const fallbackDir = path.join(base, `egregore-${safeSlug}`);
|
|
238
258
|
if (path.resolve(fallbackDir) === path.resolve(requestedDir)) {
|
|
239
259
|
throw new Error(
|
|
240
|
-
`Refusing to install into ${requestedDir}:
|
|
260
|
+
`Refusing to install into ${requestedDir}: it belongs to another Egregore or Git origin`
|
|
241
261
|
);
|
|
242
262
|
}
|
|
243
263
|
|
|
244
264
|
if (fs.existsSync(fallbackDir)) {
|
|
245
|
-
|
|
246
|
-
if (!fs.existsSync(fallbackGitPath) || !gitRemoteMatches(fallbackDir, forkUrl)) {
|
|
265
|
+
if (!installTargetMatches(fallbackDir, forkUrl, slug, orgId)) {
|
|
247
266
|
throw new Error(
|
|
248
|
-
`Refusing to install into ${fallbackDir}: the fallback path already belongs to another checkout or directory`
|
|
267
|
+
`Refusing to install into ${fallbackDir}: the fallback path already belongs to another Egregore, checkout, or directory`
|
|
249
268
|
);
|
|
250
269
|
}
|
|
251
270
|
}
|
|
@@ -444,11 +463,16 @@ async function install(data, ui, targetDir, opts = {}) {
|
|
|
444
463
|
const {
|
|
445
464
|
egregoreDir,
|
|
446
465
|
redirectedFrom,
|
|
447
|
-
} = resolveEgregoreInstallDir(
|
|
466
|
+
} = resolveEgregoreInstallDir(
|
|
467
|
+
base,
|
|
468
|
+
fork_url,
|
|
469
|
+
slug || github_org || org_name,
|
|
470
|
+
data.org_id || "",
|
|
471
|
+
);
|
|
448
472
|
if (redirectedFrom) {
|
|
449
473
|
forkDirName = path.basename(egregoreDir);
|
|
450
474
|
ui.warn(
|
|
451
|
-
`${redirectedFrom} belongs to another Git origin — leaving it untouched and installing at ${egregoreDir}`
|
|
475
|
+
`${redirectedFrom} belongs to another Egregore or Git origin — leaving it untouched and installing at ${egregoreDir}`
|
|
452
476
|
);
|
|
453
477
|
}
|
|
454
478
|
const memoryDirName = memory_url
|
|
@@ -1108,7 +1132,7 @@ function codexSupportMessage(egregoreDir) {
|
|
|
1108
1132
|
" .codex/hooks.json",
|
|
1109
1133
|
" .codex/hooks/",
|
|
1110
1134
|
"",
|
|
1111
|
-
"
|
|
1135
|
+
"Restore this instance using its installed runtime version, then rerun egregore.",
|
|
1112
1136
|
"Egregore workflows appear as slash commands. In Codex, invoke them with the matching $skill token or ask in natural language.",
|
|
1113
1137
|
].join("\n");
|
|
1114
1138
|
}
|
|
@@ -1122,7 +1146,7 @@ function piSupportMessage(egregoreDir) {
|
|
|
1122
1146
|
"Pi meta-harness support is missing from this Egregore checkout.",
|
|
1123
1147
|
` path: ${egregoreDir}`,
|
|
1124
1148
|
"",
|
|
1125
|
-
"
|
|
1149
|
+
"Restore this instance using its installed runtime version, then rerun egregore.",
|
|
1126
1150
|
"Pi loads Egregore through project-local .pi resources and the shared portable skill corpus.",
|
|
1127
1151
|
].join("\n");
|
|
1128
1152
|
}
|
|
@@ -1132,7 +1156,7 @@ function primeSupportMessage(egregoreDir) {
|
|
|
1132
1156
|
"Prime Agent runtime support is missing from this Egregore checkout.",
|
|
1133
1157
|
` path: ${egregoreDir}`,
|
|
1134
1158
|
"",
|
|
1135
|
-
"
|
|
1159
|
+
"Restore this instance using its installed runtime version, then rerun egregore.",
|
|
1136
1160
|
"Prime Agent loads Egregore through project-local .prime/agent resources and the shared portable skill corpus.",
|
|
1137
1161
|
].join("\n");
|
|
1138
1162
|
}
|
|
@@ -1176,10 +1200,23 @@ function registerInstance(slug, name, egregoreDir, runtime) {
|
|
|
1176
1200
|
}
|
|
1177
1201
|
}
|
|
1178
1202
|
|
|
1203
|
+
// The checkout config is canonical. A historic same-path install could pass
|
|
1204
|
+
// another organization's label here and splice that label onto this path.
|
|
1205
|
+
let canonicalOrgId = "";
|
|
1206
|
+
try {
|
|
1207
|
+
const config = JSON.parse(
|
|
1208
|
+
fs.readFileSync(path.join(egregoreDir, "egregore.json"), "utf-8")
|
|
1209
|
+
);
|
|
1210
|
+
slug = config.slug || slug;
|
|
1211
|
+
name = config.org_name || name;
|
|
1212
|
+
canonicalOrgId = config.org_id || "";
|
|
1213
|
+
} catch {}
|
|
1214
|
+
|
|
1179
1215
|
// Dedup by path (not slug) so multiple instances from the same org can coexist
|
|
1180
1216
|
const idx = instances.findIndex((i) => i.path === egregoreDir);
|
|
1181
1217
|
const previous = idx >= 0 ? instances[idx] : {};
|
|
1182
1218
|
const entry = { ...previous, slug, name, path: egregoreDir };
|
|
1219
|
+
if (canonicalOrgId) entry.org_id = canonicalOrgId;
|
|
1183
1220
|
if (runtime) {
|
|
1184
1221
|
entry.runtime = normalizeRuntime(runtime);
|
|
1185
1222
|
} else if (!entry.runtime && entry.preferred_runtime) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-egregore",
|
|
3
|
-
"version": "0.21.0-runtime-mvp.
|
|
3
|
+
"version": "0.21.0-runtime-mvp.24",
|
|
4
4
|
"description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code, Codex, or Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -44,18 +44,47 @@ case "${1:-ensure}" in
|
|
|
44
44
|
python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
|
|
45
45
|
else
|
|
46
46
|
api_url="$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)"
|
|
47
|
-
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
48
|
-
[ -n "$api_url" ] && [ -n "$api_key" ] || exit 1
|
|
49
|
-
# Identity repair is read-only: select the already-existing account and
|
|
50
|
-
# membership records, then adopt their opaque IDs locally. If migration
|
|
51
|
-
# 038 has not reached the control plane, the next session retries.
|
|
52
|
-
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
53
|
-
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
54
|
-
2>/dev/null || true)"
|
|
55
|
-
printf '%s' "$control_plane" | jq -e \
|
|
56
|
-
'.org_id | type == "string" and length > 0' >/dev/null 2>&1 || exit 1
|
|
57
47
|
slug="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)"
|
|
58
|
-
|
|
48
|
+
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
49
|
+
[ -n "$api_url" ] && [ -n "$slug" ] || exit 1
|
|
50
|
+
# Identity repair selects already-existing account and membership
|
|
51
|
+
# records, then adopts their opaque IDs locally. If a stale credential
|
|
52
|
+
# belongs to another org, recover only the configured org's existing key
|
|
53
|
+
# through verified GitHub membership. No control-plane records are made.
|
|
54
|
+
control_plane=""
|
|
55
|
+
if [ -n "$api_key" ]; then
|
|
56
|
+
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
57
|
+
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
58
|
+
2>/dev/null || true)"
|
|
59
|
+
fi
|
|
60
|
+
if ! printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
|
|
61
|
+
(.org_id | type == "string" and length > 0) and .slug == $slug
|
|
62
|
+
' >/dev/null 2>&1; then
|
|
63
|
+
# A historic path/namespace collision could leave this checkout with
|
|
64
|
+
# another Egregore's API key. Recover the configured org's existing key
|
|
65
|
+
# only through the membership-verifying endpoint, install it atomically,
|
|
66
|
+
# and retry. No credential bytes are printed.
|
|
67
|
+
github_token="$(grep '^GITHUB_TOKEN=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
68
|
+
[ -n "$github_token" ] || exit 1
|
|
69
|
+
encoded_slug="$(printf '%s' "$slug" | jq -sRr @uri)"
|
|
70
|
+
recovered="$(curl -sf "$api_url/api/org/$encoded_slug/key" \
|
|
71
|
+
-H "Authorization: Bearer $github_token" --connect-timeout 2 --max-time 12 \
|
|
72
|
+
2>/dev/null || true)"
|
|
73
|
+
printf '%s' "$recovered" | jq -e --arg slug "$slug" '
|
|
74
|
+
(.api_key | type == "string" and length > 0) and .org_slug == $slug
|
|
75
|
+
' >/dev/null 2>&1 || exit 1
|
|
76
|
+
printf '%s' "$recovered" | \
|
|
77
|
+
EGREGORE_ROOT="$ROOT" \
|
|
78
|
+
PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
|
79
|
+
python3 -m egregore_runtime.identity_cli install-connected-key >/dev/null
|
|
80
|
+
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
81
|
+
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
82
|
+
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
83
|
+
2>/dev/null || true)"
|
|
84
|
+
printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
|
|
85
|
+
(.org_id | type == "string" and length > 0) and .slug == $slug
|
|
86
|
+
' >/dev/null 2>&1 || exit 1
|
|
87
|
+
fi
|
|
59
88
|
roster="$(curl -sf "$api_url/api/org/$slug/members?include_removed=true" \
|
|
60
89
|
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
61
90
|
2>/dev/null || true)"
|
|
@@ -33,7 +33,7 @@ _ensure_identity() {
|
|
|
33
33
|
# through the existing verified Local/Connected migration before computing
|
|
34
34
|
# the instance-scoped upgrade key; never infer identity from path or slug.
|
|
35
35
|
if jq -e '
|
|
36
|
-
.org_id | type == "string" and length > 0
|
|
36
|
+
.org_id | type == "string" and length > 0 and (startswith("legacy-org:") | not)
|
|
37
37
|
' "$INSTANCE_ROOT/egregore.json" >/dev/null 2>&1; then
|
|
38
38
|
return 0
|
|
39
39
|
fi
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
import tempfile
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from .contracts import Permission
|
|
@@ -44,6 +45,55 @@ def _authorize_self(root: Path, permission: Permission, resource: str) -> int:
|
|
|
44
45
|
return 0
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _install_connected_key(root: Path, payload: dict) -> dict[str, str]:
|
|
49
|
+
"""Install a membership-verified key without exposing it in output."""
|
|
50
|
+
|
|
51
|
+
config = json.loads((root / "egregore.json").read_text(encoding="utf-8"))
|
|
52
|
+
expected_slug = str(config.get("slug") or "").strip()
|
|
53
|
+
returned_slug = str(payload.get("org_slug") or "").strip()
|
|
54
|
+
api_key = str(payload.get("api_key") or "").strip()
|
|
55
|
+
if not expected_slug or returned_slug != expected_slug:
|
|
56
|
+
raise ValueError("recovered credential does not match the configured organization")
|
|
57
|
+
if not api_key or "\n" in api_key or "\r" in api_key:
|
|
58
|
+
raise ValueError("recovered credential is missing or malformed")
|
|
59
|
+
|
|
60
|
+
env_path = root / ".env"
|
|
61
|
+
existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
|
|
62
|
+
output: list[str] = []
|
|
63
|
+
replaced = False
|
|
64
|
+
for line in existing.splitlines():
|
|
65
|
+
if line.startswith("EGREGORE_API_KEY="):
|
|
66
|
+
if not replaced:
|
|
67
|
+
output.append(f"EGREGORE_API_KEY={api_key}")
|
|
68
|
+
replaced = True
|
|
69
|
+
continue
|
|
70
|
+
output.append(line)
|
|
71
|
+
if not replaced:
|
|
72
|
+
output.append(f"EGREGORE_API_KEY={api_key}")
|
|
73
|
+
|
|
74
|
+
temporary: Path | None = None
|
|
75
|
+
try:
|
|
76
|
+
with tempfile.NamedTemporaryFile(
|
|
77
|
+
mode="w",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
dir=root,
|
|
80
|
+
prefix=".env.egregore-key.",
|
|
81
|
+
delete=False,
|
|
82
|
+
) as handle:
|
|
83
|
+
temporary = Path(handle.name)
|
|
84
|
+
os.chmod(temporary, 0o600)
|
|
85
|
+
handle.write("\n".join(output) + "\n")
|
|
86
|
+
handle.flush()
|
|
87
|
+
os.fsync(handle.fileno())
|
|
88
|
+
os.replace(temporary, env_path)
|
|
89
|
+
temporary = None
|
|
90
|
+
finally:
|
|
91
|
+
if temporary is not None:
|
|
92
|
+
temporary.unlink(missing_ok=True)
|
|
93
|
+
|
|
94
|
+
return {"org_slug": expected_slug}
|
|
95
|
+
|
|
96
|
+
|
|
47
97
|
def main() -> int:
|
|
48
98
|
root = Path(os.environ.get("EGREGORE_ROOT") or Path.cwd()).resolve()
|
|
49
99
|
if len(sys.argv) > 1 and sys.argv[1] == "initialize-local":
|
|
@@ -77,6 +127,11 @@ def main() -> int:
|
|
|
77
127
|
result = reconcile_control_plane_identity(root, response)
|
|
78
128
|
print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
|
|
79
129
|
return 0
|
|
130
|
+
if len(sys.argv) > 1 and sys.argv[1] == "install-connected-key":
|
|
131
|
+
payload = json.load(sys.stdin)
|
|
132
|
+
result = _install_connected_key(root, payload)
|
|
133
|
+
print(json.dumps({"status": "installed", **result}, sort_keys=True))
|
|
134
|
+
return 0
|
|
80
135
|
payload = json.load(sys.stdin)
|
|
81
136
|
result = reconcile_control_plane_identity(root, payload)
|
|
82
137
|
print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
|
|
@@ -44,18 +44,47 @@ case "${1:-ensure}" in
|
|
|
44
44
|
python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
|
|
45
45
|
else
|
|
46
46
|
api_url="$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)"
|
|
47
|
-
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
48
|
-
[ -n "$api_url" ] && [ -n "$api_key" ] || exit 1
|
|
49
|
-
# Identity repair is read-only: select the already-existing account and
|
|
50
|
-
# membership records, then adopt their opaque IDs locally. If migration
|
|
51
|
-
# 038 has not reached the control plane, the next session retries.
|
|
52
|
-
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
53
|
-
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
54
|
-
2>/dev/null || true)"
|
|
55
|
-
printf '%s' "$control_plane" | jq -e \
|
|
56
|
-
'.org_id | type == "string" and length > 0' >/dev/null 2>&1 || exit 1
|
|
57
47
|
slug="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)"
|
|
58
|
-
|
|
48
|
+
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
49
|
+
[ -n "$api_url" ] && [ -n "$slug" ] || exit 1
|
|
50
|
+
# Identity repair selects already-existing account and membership
|
|
51
|
+
# records, then adopts their opaque IDs locally. If a stale credential
|
|
52
|
+
# belongs to another org, recover only the configured org's existing key
|
|
53
|
+
# through verified GitHub membership. No control-plane records are made.
|
|
54
|
+
control_plane=""
|
|
55
|
+
if [ -n "$api_key" ]; then
|
|
56
|
+
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
57
|
+
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
58
|
+
2>/dev/null || true)"
|
|
59
|
+
fi
|
|
60
|
+
if ! printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
|
|
61
|
+
(.org_id | type == "string" and length > 0) and .slug == $slug
|
|
62
|
+
' >/dev/null 2>&1; then
|
|
63
|
+
# A historic path/namespace collision could leave this checkout with
|
|
64
|
+
# another Egregore's API key. Recover the configured org's existing key
|
|
65
|
+
# only through the membership-verifying endpoint, install it atomically,
|
|
66
|
+
# and retry. No credential bytes are printed.
|
|
67
|
+
github_token="$(grep '^GITHUB_TOKEN=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
68
|
+
[ -n "$github_token" ] || exit 1
|
|
69
|
+
encoded_slug="$(printf '%s' "$slug" | jq -sRr @uri)"
|
|
70
|
+
recovered="$(curl -sf "$api_url/api/org/$encoded_slug/key" \
|
|
71
|
+
-H "Authorization: Bearer $github_token" --connect-timeout 2 --max-time 12 \
|
|
72
|
+
2>/dev/null || true)"
|
|
73
|
+
printf '%s' "$recovered" | jq -e --arg slug "$slug" '
|
|
74
|
+
(.api_key | type == "string" and length > 0) and .org_slug == $slug
|
|
75
|
+
' >/dev/null 2>&1 || exit 1
|
|
76
|
+
printf '%s' "$recovered" | \
|
|
77
|
+
EGREGORE_ROOT="$ROOT" \
|
|
78
|
+
PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
|
79
|
+
python3 -m egregore_runtime.identity_cli install-connected-key >/dev/null
|
|
80
|
+
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
81
|
+
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
82
|
+
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
83
|
+
2>/dev/null || true)"
|
|
84
|
+
printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
|
|
85
|
+
(.org_id | type == "string" and length > 0) and .slug == $slug
|
|
86
|
+
' >/dev/null 2>&1 || exit 1
|
|
87
|
+
fi
|
|
59
88
|
roster="$(curl -sf "$api_url/api/org/$slug/members?include_removed=true" \
|
|
60
89
|
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
61
90
|
2>/dev/null || true)"
|
|
@@ -33,7 +33,7 @@ _ensure_identity() {
|
|
|
33
33
|
# through the existing verified Local/Connected migration before computing
|
|
34
34
|
# the instance-scoped upgrade key; never infer identity from path or slug.
|
|
35
35
|
if jq -e '
|
|
36
|
-
.org_id | type == "string" and length > 0
|
|
36
|
+
.org_id | type == "string" and length > 0 and (startswith("legacy-org:") | not)
|
|
37
37
|
' "$INSTANCE_ROOT/egregore.json" >/dev/null 2>&1; then
|
|
38
38
|
return 0
|
|
39
39
|
fi
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
import tempfile
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from .contracts import Permission
|
|
@@ -44,6 +45,55 @@ def _authorize_self(root: Path, permission: Permission, resource: str) -> int:
|
|
|
44
45
|
return 0
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _install_connected_key(root: Path, payload: dict) -> dict[str, str]:
|
|
49
|
+
"""Install a membership-verified key without exposing it in output."""
|
|
50
|
+
|
|
51
|
+
config = json.loads((root / "egregore.json").read_text(encoding="utf-8"))
|
|
52
|
+
expected_slug = str(config.get("slug") or "").strip()
|
|
53
|
+
returned_slug = str(payload.get("org_slug") or "").strip()
|
|
54
|
+
api_key = str(payload.get("api_key") or "").strip()
|
|
55
|
+
if not expected_slug or returned_slug != expected_slug:
|
|
56
|
+
raise ValueError("recovered credential does not match the configured organization")
|
|
57
|
+
if not api_key or "\n" in api_key or "\r" in api_key:
|
|
58
|
+
raise ValueError("recovered credential is missing or malformed")
|
|
59
|
+
|
|
60
|
+
env_path = root / ".env"
|
|
61
|
+
existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
|
|
62
|
+
output: list[str] = []
|
|
63
|
+
replaced = False
|
|
64
|
+
for line in existing.splitlines():
|
|
65
|
+
if line.startswith("EGREGORE_API_KEY="):
|
|
66
|
+
if not replaced:
|
|
67
|
+
output.append(f"EGREGORE_API_KEY={api_key}")
|
|
68
|
+
replaced = True
|
|
69
|
+
continue
|
|
70
|
+
output.append(line)
|
|
71
|
+
if not replaced:
|
|
72
|
+
output.append(f"EGREGORE_API_KEY={api_key}")
|
|
73
|
+
|
|
74
|
+
temporary: Path | None = None
|
|
75
|
+
try:
|
|
76
|
+
with tempfile.NamedTemporaryFile(
|
|
77
|
+
mode="w",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
dir=root,
|
|
80
|
+
prefix=".env.egregore-key.",
|
|
81
|
+
delete=False,
|
|
82
|
+
) as handle:
|
|
83
|
+
temporary = Path(handle.name)
|
|
84
|
+
os.chmod(temporary, 0o600)
|
|
85
|
+
handle.write("\n".join(output) + "\n")
|
|
86
|
+
handle.flush()
|
|
87
|
+
os.fsync(handle.fileno())
|
|
88
|
+
os.replace(temporary, env_path)
|
|
89
|
+
temporary = None
|
|
90
|
+
finally:
|
|
91
|
+
if temporary is not None:
|
|
92
|
+
temporary.unlink(missing_ok=True)
|
|
93
|
+
|
|
94
|
+
return {"org_slug": expected_slug}
|
|
95
|
+
|
|
96
|
+
|
|
47
97
|
def main() -> int:
|
|
48
98
|
root = Path(os.environ.get("EGREGORE_ROOT") or Path.cwd()).resolve()
|
|
49
99
|
if len(sys.argv) > 1 and sys.argv[1] == "initialize-local":
|
|
@@ -77,6 +127,11 @@ def main() -> int:
|
|
|
77
127
|
result = reconcile_control_plane_identity(root, response)
|
|
78
128
|
print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
|
|
79
129
|
return 0
|
|
130
|
+
if len(sys.argv) > 1 and sys.argv[1] == "install-connected-key":
|
|
131
|
+
payload = json.load(sys.stdin)
|
|
132
|
+
result = _install_connected_key(root, payload)
|
|
133
|
+
print(json.dumps({"status": "installed", **result}, sort_keys=True))
|
|
134
|
+
return 0
|
|
80
135
|
payload = json.load(sys.stdin)
|
|
81
136
|
result = reconcile_control_plane_identity(root, payload)
|
|
82
137
|
print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
|
|
@@ -44,18 +44,47 @@ case "${1:-ensure}" in
|
|
|
44
44
|
python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
|
|
45
45
|
else
|
|
46
46
|
api_url="$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)"
|
|
47
|
-
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
48
|
-
[ -n "$api_url" ] && [ -n "$api_key" ] || exit 1
|
|
49
|
-
# Identity repair is read-only: select the already-existing account and
|
|
50
|
-
# membership records, then adopt their opaque IDs locally. If migration
|
|
51
|
-
# 038 has not reached the control plane, the next session retries.
|
|
52
|
-
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
53
|
-
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
54
|
-
2>/dev/null || true)"
|
|
55
|
-
printf '%s' "$control_plane" | jq -e \
|
|
56
|
-
'.org_id | type == "string" and length > 0' >/dev/null 2>&1 || exit 1
|
|
57
47
|
slug="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)"
|
|
58
|
-
|
|
48
|
+
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
49
|
+
[ -n "$api_url" ] && [ -n "$slug" ] || exit 1
|
|
50
|
+
# Identity repair selects already-existing account and membership
|
|
51
|
+
# records, then adopts their opaque IDs locally. If a stale credential
|
|
52
|
+
# belongs to another org, recover only the configured org's existing key
|
|
53
|
+
# through verified GitHub membership. No control-plane records are made.
|
|
54
|
+
control_plane=""
|
|
55
|
+
if [ -n "$api_key" ]; then
|
|
56
|
+
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
57
|
+
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
58
|
+
2>/dev/null || true)"
|
|
59
|
+
fi
|
|
60
|
+
if ! printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
|
|
61
|
+
(.org_id | type == "string" and length > 0) and .slug == $slug
|
|
62
|
+
' >/dev/null 2>&1; then
|
|
63
|
+
# A historic path/namespace collision could leave this checkout with
|
|
64
|
+
# another Egregore's API key. Recover the configured org's existing key
|
|
65
|
+
# only through the membership-verifying endpoint, install it atomically,
|
|
66
|
+
# and retry. No credential bytes are printed.
|
|
67
|
+
github_token="$(grep '^GITHUB_TOKEN=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
68
|
+
[ -n "$github_token" ] || exit 1
|
|
69
|
+
encoded_slug="$(printf '%s' "$slug" | jq -sRr @uri)"
|
|
70
|
+
recovered="$(curl -sf "$api_url/api/org/$encoded_slug/key" \
|
|
71
|
+
-H "Authorization: Bearer $github_token" --connect-timeout 2 --max-time 12 \
|
|
72
|
+
2>/dev/null || true)"
|
|
73
|
+
printf '%s' "$recovered" | jq -e --arg slug "$slug" '
|
|
74
|
+
(.api_key | type == "string" and length > 0) and .org_slug == $slug
|
|
75
|
+
' >/dev/null 2>&1 || exit 1
|
|
76
|
+
printf '%s' "$recovered" | \
|
|
77
|
+
EGREGORE_ROOT="$ROOT" \
|
|
78
|
+
PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
|
79
|
+
python3 -m egregore_runtime.identity_cli install-connected-key >/dev/null
|
|
80
|
+
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
81
|
+
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
82
|
+
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
83
|
+
2>/dev/null || true)"
|
|
84
|
+
printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
|
|
85
|
+
(.org_id | type == "string" and length > 0) and .slug == $slug
|
|
86
|
+
' >/dev/null 2>&1 || exit 1
|
|
87
|
+
fi
|
|
59
88
|
roster="$(curl -sf "$api_url/api/org/$slug/members?include_removed=true" \
|
|
60
89
|
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
61
90
|
2>/dev/null || true)"
|
|
@@ -33,7 +33,7 @@ _ensure_identity() {
|
|
|
33
33
|
# through the existing verified Local/Connected migration before computing
|
|
34
34
|
# the instance-scoped upgrade key; never infer identity from path or slug.
|
|
35
35
|
if jq -e '
|
|
36
|
-
.org_id | type == "string" and length > 0
|
|
36
|
+
.org_id | type == "string" and length > 0 and (startswith("legacy-org:") | not)
|
|
37
37
|
' "$INSTANCE_ROOT/egregore.json" >/dev/null 2>&1; then
|
|
38
38
|
return 0
|
|
39
39
|
fi
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
import tempfile
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from .contracts import Permission
|
|
@@ -44,6 +45,55 @@ def _authorize_self(root: Path, permission: Permission, resource: str) -> int:
|
|
|
44
45
|
return 0
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _install_connected_key(root: Path, payload: dict) -> dict[str, str]:
|
|
49
|
+
"""Install a membership-verified key without exposing it in output."""
|
|
50
|
+
|
|
51
|
+
config = json.loads((root / "egregore.json").read_text(encoding="utf-8"))
|
|
52
|
+
expected_slug = str(config.get("slug") or "").strip()
|
|
53
|
+
returned_slug = str(payload.get("org_slug") or "").strip()
|
|
54
|
+
api_key = str(payload.get("api_key") or "").strip()
|
|
55
|
+
if not expected_slug or returned_slug != expected_slug:
|
|
56
|
+
raise ValueError("recovered credential does not match the configured organization")
|
|
57
|
+
if not api_key or "\n" in api_key or "\r" in api_key:
|
|
58
|
+
raise ValueError("recovered credential is missing or malformed")
|
|
59
|
+
|
|
60
|
+
env_path = root / ".env"
|
|
61
|
+
existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
|
|
62
|
+
output: list[str] = []
|
|
63
|
+
replaced = False
|
|
64
|
+
for line in existing.splitlines():
|
|
65
|
+
if line.startswith("EGREGORE_API_KEY="):
|
|
66
|
+
if not replaced:
|
|
67
|
+
output.append(f"EGREGORE_API_KEY={api_key}")
|
|
68
|
+
replaced = True
|
|
69
|
+
continue
|
|
70
|
+
output.append(line)
|
|
71
|
+
if not replaced:
|
|
72
|
+
output.append(f"EGREGORE_API_KEY={api_key}")
|
|
73
|
+
|
|
74
|
+
temporary: Path | None = None
|
|
75
|
+
try:
|
|
76
|
+
with tempfile.NamedTemporaryFile(
|
|
77
|
+
mode="w",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
dir=root,
|
|
80
|
+
prefix=".env.egregore-key.",
|
|
81
|
+
delete=False,
|
|
82
|
+
) as handle:
|
|
83
|
+
temporary = Path(handle.name)
|
|
84
|
+
os.chmod(temporary, 0o600)
|
|
85
|
+
handle.write("\n".join(output) + "\n")
|
|
86
|
+
handle.flush()
|
|
87
|
+
os.fsync(handle.fileno())
|
|
88
|
+
os.replace(temporary, env_path)
|
|
89
|
+
temporary = None
|
|
90
|
+
finally:
|
|
91
|
+
if temporary is not None:
|
|
92
|
+
temporary.unlink(missing_ok=True)
|
|
93
|
+
|
|
94
|
+
return {"org_slug": expected_slug}
|
|
95
|
+
|
|
96
|
+
|
|
47
97
|
def main() -> int:
|
|
48
98
|
root = Path(os.environ.get("EGREGORE_ROOT") or Path.cwd()).resolve()
|
|
49
99
|
if len(sys.argv) > 1 and sys.argv[1] == "initialize-local":
|
|
@@ -77,6 +127,11 @@ def main() -> int:
|
|
|
77
127
|
result = reconcile_control_plane_identity(root, response)
|
|
78
128
|
print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
|
|
79
129
|
return 0
|
|
130
|
+
if len(sys.argv) > 1 and sys.argv[1] == "install-connected-key":
|
|
131
|
+
payload = json.load(sys.stdin)
|
|
132
|
+
result = _install_connected_key(root, payload)
|
|
133
|
+
print(json.dumps({"status": "installed", **result}, sort_keys=True))
|
|
134
|
+
return 0
|
|
80
135
|
payload = json.load(sys.stdin)
|
|
81
136
|
result = reconcile_control_plane_identity(root, payload)
|
|
82
137
|
print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
|
|
@@ -44,18 +44,47 @@ case "${1:-ensure}" in
|
|
|
44
44
|
python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
|
|
45
45
|
else
|
|
46
46
|
api_url="$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)"
|
|
47
|
-
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
48
|
-
[ -n "$api_url" ] && [ -n "$api_key" ] || exit 1
|
|
49
|
-
# Identity repair is read-only: select the already-existing account and
|
|
50
|
-
# membership records, then adopt their opaque IDs locally. If migration
|
|
51
|
-
# 038 has not reached the control plane, the next session retries.
|
|
52
|
-
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
53
|
-
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
54
|
-
2>/dev/null || true)"
|
|
55
|
-
printf '%s' "$control_plane" | jq -e \
|
|
56
|
-
'.org_id | type == "string" and length > 0' >/dev/null 2>&1 || exit 1
|
|
57
47
|
slug="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)"
|
|
58
|
-
|
|
48
|
+
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
49
|
+
[ -n "$api_url" ] && [ -n "$slug" ] || exit 1
|
|
50
|
+
# Identity repair selects already-existing account and membership
|
|
51
|
+
# records, then adopts their opaque IDs locally. If a stale credential
|
|
52
|
+
# belongs to another org, recover only the configured org's existing key
|
|
53
|
+
# through verified GitHub membership. No control-plane records are made.
|
|
54
|
+
control_plane=""
|
|
55
|
+
if [ -n "$api_key" ]; then
|
|
56
|
+
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
57
|
+
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
58
|
+
2>/dev/null || true)"
|
|
59
|
+
fi
|
|
60
|
+
if ! printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
|
|
61
|
+
(.org_id | type == "string" and length > 0) and .slug == $slug
|
|
62
|
+
' >/dev/null 2>&1; then
|
|
63
|
+
# A historic path/namespace collision could leave this checkout with
|
|
64
|
+
# another Egregore's API key. Recover the configured org's existing key
|
|
65
|
+
# only through the membership-verifying endpoint, install it atomically,
|
|
66
|
+
# and retry. No credential bytes are printed.
|
|
67
|
+
github_token="$(grep '^GITHUB_TOKEN=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
68
|
+
[ -n "$github_token" ] || exit 1
|
|
69
|
+
encoded_slug="$(printf '%s' "$slug" | jq -sRr @uri)"
|
|
70
|
+
recovered="$(curl -sf "$api_url/api/org/$encoded_slug/key" \
|
|
71
|
+
-H "Authorization: Bearer $github_token" --connect-timeout 2 --max-time 12 \
|
|
72
|
+
2>/dev/null || true)"
|
|
73
|
+
printf '%s' "$recovered" | jq -e --arg slug "$slug" '
|
|
74
|
+
(.api_key | type == "string" and length > 0) and .org_slug == $slug
|
|
75
|
+
' >/dev/null 2>&1 || exit 1
|
|
76
|
+
printf '%s' "$recovered" | \
|
|
77
|
+
EGREGORE_ROOT="$ROOT" \
|
|
78
|
+
PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
|
79
|
+
python3 -m egregore_runtime.identity_cli install-connected-key >/dev/null
|
|
80
|
+
api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
|
|
81
|
+
control_plane="$(curl -sf "$api_url/api/org/status" \
|
|
82
|
+
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
83
|
+
2>/dev/null || true)"
|
|
84
|
+
printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
|
|
85
|
+
(.org_id | type == "string" and length > 0) and .slug == $slug
|
|
86
|
+
' >/dev/null 2>&1 || exit 1
|
|
87
|
+
fi
|
|
59
88
|
roster="$(curl -sf "$api_url/api/org/$slug/members?include_removed=true" \
|
|
60
89
|
-H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
|
|
61
90
|
2>/dev/null || true)"
|
|
@@ -33,7 +33,7 @@ _ensure_identity() {
|
|
|
33
33
|
# through the existing verified Local/Connected migration before computing
|
|
34
34
|
# the instance-scoped upgrade key; never infer identity from path or slug.
|
|
35
35
|
if jq -e '
|
|
36
|
-
.org_id | type == "string" and length > 0
|
|
36
|
+
.org_id | type == "string" and length > 0 and (startswith("legacy-org:") | not)
|
|
37
37
|
' "$INSTANCE_ROOT/egregore.json" >/dev/null 2>&1; then
|
|
38
38
|
return 0
|
|
39
39
|
fi
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
import tempfile
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from .contracts import Permission
|
|
@@ -44,6 +45,55 @@ def _authorize_self(root: Path, permission: Permission, resource: str) -> int:
|
|
|
44
45
|
return 0
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _install_connected_key(root: Path, payload: dict) -> dict[str, str]:
|
|
49
|
+
"""Install a membership-verified key without exposing it in output."""
|
|
50
|
+
|
|
51
|
+
config = json.loads((root / "egregore.json").read_text(encoding="utf-8"))
|
|
52
|
+
expected_slug = str(config.get("slug") or "").strip()
|
|
53
|
+
returned_slug = str(payload.get("org_slug") or "").strip()
|
|
54
|
+
api_key = str(payload.get("api_key") or "").strip()
|
|
55
|
+
if not expected_slug or returned_slug != expected_slug:
|
|
56
|
+
raise ValueError("recovered credential does not match the configured organization")
|
|
57
|
+
if not api_key or "\n" in api_key or "\r" in api_key:
|
|
58
|
+
raise ValueError("recovered credential is missing or malformed")
|
|
59
|
+
|
|
60
|
+
env_path = root / ".env"
|
|
61
|
+
existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
|
|
62
|
+
output: list[str] = []
|
|
63
|
+
replaced = False
|
|
64
|
+
for line in existing.splitlines():
|
|
65
|
+
if line.startswith("EGREGORE_API_KEY="):
|
|
66
|
+
if not replaced:
|
|
67
|
+
output.append(f"EGREGORE_API_KEY={api_key}")
|
|
68
|
+
replaced = True
|
|
69
|
+
continue
|
|
70
|
+
output.append(line)
|
|
71
|
+
if not replaced:
|
|
72
|
+
output.append(f"EGREGORE_API_KEY={api_key}")
|
|
73
|
+
|
|
74
|
+
temporary: Path | None = None
|
|
75
|
+
try:
|
|
76
|
+
with tempfile.NamedTemporaryFile(
|
|
77
|
+
mode="w",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
dir=root,
|
|
80
|
+
prefix=".env.egregore-key.",
|
|
81
|
+
delete=False,
|
|
82
|
+
) as handle:
|
|
83
|
+
temporary = Path(handle.name)
|
|
84
|
+
os.chmod(temporary, 0o600)
|
|
85
|
+
handle.write("\n".join(output) + "\n")
|
|
86
|
+
handle.flush()
|
|
87
|
+
os.fsync(handle.fileno())
|
|
88
|
+
os.replace(temporary, env_path)
|
|
89
|
+
temporary = None
|
|
90
|
+
finally:
|
|
91
|
+
if temporary is not None:
|
|
92
|
+
temporary.unlink(missing_ok=True)
|
|
93
|
+
|
|
94
|
+
return {"org_slug": expected_slug}
|
|
95
|
+
|
|
96
|
+
|
|
47
97
|
def main() -> int:
|
|
48
98
|
root = Path(os.environ.get("EGREGORE_ROOT") or Path.cwd()).resolve()
|
|
49
99
|
if len(sys.argv) > 1 and sys.argv[1] == "initialize-local":
|
|
@@ -77,6 +127,11 @@ def main() -> int:
|
|
|
77
127
|
result = reconcile_control_plane_identity(root, response)
|
|
78
128
|
print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
|
|
79
129
|
return 0
|
|
130
|
+
if len(sys.argv) > 1 and sys.argv[1] == "install-connected-key":
|
|
131
|
+
payload = json.load(sys.stdin)
|
|
132
|
+
result = _install_connected_key(root, payload)
|
|
133
|
+
print(json.dumps({"status": "installed", **result}, sort_keys=True))
|
|
134
|
+
return 0
|
|
80
135
|
payload = json.load(sys.stdin)
|
|
81
136
|
result = reconcile_control_plane_identity(root, payload)
|
|
82
137
|
print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
|