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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.22
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.22";
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
- result.push(e);
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
- "Automatic repair did not complete. Retry with:",
754
- " npx -y create-egregore@latest --refresh-launcher",
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
- "Run npx -y create-egregore@latest to install the packaged Pi runtime, then rerun egregore.",
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
- "Run npx -y create-egregore@latest to install the packaged Prime Agent runtime, then rerun egregore.",
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
- if (!hasCodexCoreSupport(inst.path)) {
976
- if (!repairPortableRuntime(inst.path, runtime) || !hasCodexCoreSupport(inst.path)) {
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
- let coreOk = hasPrimeCoreSupport(inst.path);
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}@latest`, "--refresh-launcher"],
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}@latest --refresh-launcher${RESET}`);
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 is already a Git checkout with another
223
- * origin, leave it untouched and use egregore-{org-slug}. The fallback must
224
- * also be absent or point at the requested fork; otherwise stop rather than
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 resolveEgregoreInstallDir(base, forkUrl, slug) {
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 (!fs.existsSync(requestedGitPath) || gitRemoteMatches(requestedDir, forkUrl)) {
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}: its Git origin does not match ${forkUrl}`
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
- const fallbackGitPath = path.join(fallbackDir, ".git");
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(base, fork_url, slug || github_org || org_name);
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
- "Run npx -y create-egregore@latest to install the packaged Codex runtime, then rerun egregore.",
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
- "Run npx -y create-egregore@latest to install the packaged Pi runtime, then rerun egregore.",
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
- "Run npx -y create-egregore@latest to install the packaged Prime Agent runtime, then rerun egregore.",
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.22",
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
- [ -n "$slug" ] || exit 1
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
- [ -n "$slug" ] || exit 1
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
- [ -n "$slug" ] || exit 1
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
- [ -n "$slug" ] || exit 1
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))