convene-cli 1.0.0
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/LICENSE +21 -0
- package/dist/api.js +110 -0
- package/dist/brand.js +31 -0
- package/dist/cache.js +39 -0
- package/dist/commands/auth.js +151 -0
- package/dist/commands/fetch.js +115 -0
- package/dist/commands/inbox.js +36 -0
- package/dist/commands/init.js +319 -0
- package/dist/commands/join.js +80 -0
- package/dist/commands/migrate.js +21 -0
- package/dist/commands/notify.js +164 -0
- package/dist/commands/post.js +65 -0
- package/dist/commands/rotate.js +56 -0
- package/dist/commands/setup.js +44 -0
- package/dist/config.js +101 -0
- package/dist/ctx.js +44 -0
- package/dist/git.js +206 -0
- package/dist/githook.js +116 -0
- package/dist/hook.js +83 -0
- package/dist/index.js +160 -0
- package/dist/protocol.js +134 -0
- package/dist/render.js +118 -0
- package/dist/test-env.js +17 -0
- package/package.json +46 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.upsertMarkerBlock = upsertMarkerBlock;
|
|
7
|
+
exports.init = init;
|
|
8
|
+
/**
|
|
9
|
+
* `convene init` — one-command repo onboarding. IDEMPOTENT + merge-safe
|
|
10
|
+
* (P0-IDEMPOTENT): running twice yields a byte-identical repo. Every managed file
|
|
11
|
+
* is upserted by stable markers; edits are backed up and only written when the
|
|
12
|
+
* content actually changes.
|
|
13
|
+
*/
|
|
14
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const brand_1 = require("../brand");
|
|
17
|
+
const config_1 = require("../config");
|
|
18
|
+
const git_1 = require("../git");
|
|
19
|
+
const api_1 = require("../api");
|
|
20
|
+
const protocol_1 = require("../protocol");
|
|
21
|
+
const hook_1 = require("../hook");
|
|
22
|
+
const githook_1 = require("../githook");
|
|
23
|
+
const ctx_1 = require("../ctx");
|
|
24
|
+
const log = (m) => process.stdout.write(m + '\n');
|
|
25
|
+
/** Replace content between the convene markers, or append the block if absent. */
|
|
26
|
+
function upsertMarkerBlock(content, block) {
|
|
27
|
+
const b = brand_1.BRAND.blockBegin;
|
|
28
|
+
const e = brand_1.BRAND.blockEnd;
|
|
29
|
+
const start = content.indexOf(b);
|
|
30
|
+
const end = content.indexOf(e);
|
|
31
|
+
if (start >= 0 && end > start) {
|
|
32
|
+
return content.slice(0, start) + block + content.slice(end + e.length);
|
|
33
|
+
}
|
|
34
|
+
const sep = content.length === 0 ? '' : content.endsWith('\n') ? '\n' : '\n\n';
|
|
35
|
+
return content + sep + block + '\n';
|
|
36
|
+
}
|
|
37
|
+
function writeIfChanged(file, content, backup = true) {
|
|
38
|
+
const exists = node_fs_1.default.existsSync(file);
|
|
39
|
+
const old = exists ? node_fs_1.default.readFileSync(file, 'utf8') : null;
|
|
40
|
+
if (old === content)
|
|
41
|
+
return 'unchanged';
|
|
42
|
+
if (exists && backup)
|
|
43
|
+
node_fs_1.default.writeFileSync(file + '.bak', old);
|
|
44
|
+
node_fs_1.default.writeFileSync(file, content);
|
|
45
|
+
return exists ? 'updated' : 'created';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A `.gitignore` line that ignores the WHOLE `.convene/` directory (e.g. `.convene/`,
|
|
49
|
+
* `.convene`, `/.convene`), as opposed to a granular sub-path like `.convene/cache/`.
|
|
50
|
+
* Comments (`#`) and negations (`!`) are not blanket rules.
|
|
51
|
+
*/
|
|
52
|
+
function isBlanketConveneIgnore(line) {
|
|
53
|
+
const t = line.trim();
|
|
54
|
+
if (!t || t.startsWith('#') || t.startsWith('!'))
|
|
55
|
+
return false;
|
|
56
|
+
return t.replace(/^\//, '').replace(/\/+$/, '') === '.convene';
|
|
57
|
+
}
|
|
58
|
+
function ensureGitignoreGuard(top) {
|
|
59
|
+
const file = node_path_1.default.join(top, '.gitignore');
|
|
60
|
+
const marker = '# convene';
|
|
61
|
+
const guard = `${marker} (keep local cache out of git; .convene/project.json IS committed)\n.convene/cache/\n.convene/*.local.json\n`;
|
|
62
|
+
const old = node_fs_1.default.existsSync(file) ? node_fs_1.default.readFileSync(file, 'utf8') : '';
|
|
63
|
+
// A pre-existing blanket `.convene/` rule ignores the whole directory, so git
|
|
64
|
+
// never descends into it and the committed `.convene/project.json` (which carries
|
|
65
|
+
// the join token) stays untracked — `git add .convene/project.json` is a silent
|
|
66
|
+
// no-op and every teammate's `convene join` fails with "no join token". A
|
|
67
|
+
// `!.convene/project.json` negation CANNOT rescue a file under an ignored parent,
|
|
68
|
+
// so we comment the blanket rule out outright and rely on the granular guard below.
|
|
69
|
+
const rewritten = old
|
|
70
|
+
.split('\n')
|
|
71
|
+
.map((line) => isBlanketConveneIgnore(line)
|
|
72
|
+
? `# ${line.trim()} (disabled by convene init: this hid the committed .convene/project.json + its join token)`
|
|
73
|
+
: line)
|
|
74
|
+
.join('\n');
|
|
75
|
+
// Detect the guard by its granular content (not the bare marker) so a stray
|
|
76
|
+
// "# convene" comment elsewhere can't trick us into skipping the real excludes.
|
|
77
|
+
const hasGuard = rewritten.includes('.convene/cache/') && rewritten.includes('.convene/*.local.json');
|
|
78
|
+
let next = rewritten;
|
|
79
|
+
if (!hasGuard) {
|
|
80
|
+
const sep = next.length === 0 ? '' : next.endsWith('\n') ? '' : '\n';
|
|
81
|
+
next = next + sep + guard;
|
|
82
|
+
}
|
|
83
|
+
if (next !== old) {
|
|
84
|
+
if (node_fs_1.default.existsSync(file))
|
|
85
|
+
node_fs_1.default.writeFileSync(file + '.bak', old);
|
|
86
|
+
node_fs_1.default.writeFileSync(file, next);
|
|
87
|
+
}
|
|
88
|
+
// Belt-and-suspenders: confirm the join-token-bearing file is actually trackable.
|
|
89
|
+
// A blanket rule may also live in a parent dir's .gitignore or a global excludes
|
|
90
|
+
// file we don't own — warn loudly rather than let onboarding silently break.
|
|
91
|
+
if ((0, git_1.gitPathIsIgnored)(top, node_path_1.default.join('.convene', 'project.json'))) {
|
|
92
|
+
log('⚠ .convene/project.json is STILL git-ignored after writing the guard —');
|
|
93
|
+
log(' teammates\' `convene join` will fail with "no join token".');
|
|
94
|
+
log(' Find the offending rule with `git check-ignore -v .convene/project.json`');
|
|
95
|
+
log(' (it may be in a parent .gitignore or your global core.excludesfile), remove it,');
|
|
96
|
+
log(' then `git add -f .convene/project.json`.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function hookSnippet() {
|
|
100
|
+
return JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: hook_1.HOOK_COMMAND }] }] } }, null, 2);
|
|
101
|
+
}
|
|
102
|
+
function registerHook(noHook) {
|
|
103
|
+
if (noHook) {
|
|
104
|
+
log('Skipped hook registration (--no-hook). Add this to ~/.claude/settings.json:');
|
|
105
|
+
log(hookSnippet());
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const raw = (0, hook_1.readSettingsRaw)();
|
|
109
|
+
const settings = (0, hook_1.parseSettings)(raw);
|
|
110
|
+
if (settings === null) {
|
|
111
|
+
log(`Could not safely parse ${hook_1.SETTINGS_PATH} — add this hook manually:`);
|
|
112
|
+
log(hookSnippet());
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if ((0, hook_1.hookIsRegistered)(settings)) {
|
|
116
|
+
log('Hook already registered (UserPromptSubmit `convene fetch`).');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(hook_1.SETTINGS_PATH), { recursive: true });
|
|
121
|
+
if (raw !== null)
|
|
122
|
+
node_fs_1.default.writeFileSync(hook_1.SETTINGS_PATH + '.bak', raw);
|
|
123
|
+
node_fs_1.default.writeFileSync(hook_1.SETTINGS_PATH, (0, hook_1.serializeSettings)((0, hook_1.withHook)(settings)));
|
|
124
|
+
log(`Registered UserPromptSubmit hook in ${hook_1.SETTINGS_PATH}${raw !== null ? ' (backup: settings.json.bak)' : ''}.`);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
log(`Could not write settings (${err?.message}). Add this hook manually:`);
|
|
128
|
+
log(hookSnippet());
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function installGitHookStep(top, skip) {
|
|
132
|
+
if (skip) {
|
|
133
|
+
log('Skipped git pre-push hook (--no-githook).');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const r = (0, githook_1.installGitHooks)(top);
|
|
137
|
+
switch (r.status) {
|
|
138
|
+
case 'installed':
|
|
139
|
+
case 'updated':
|
|
140
|
+
log(`✓ .githooks/pre-push (${r.status}) + core.hooksPath → auto-posts a [STATUS] on push`);
|
|
141
|
+
break;
|
|
142
|
+
case 'already':
|
|
143
|
+
log('· .githooks/pre-push (already installed)');
|
|
144
|
+
break;
|
|
145
|
+
case 'skipped-foreign':
|
|
146
|
+
log(`· git pre-push hook skipped — core.hooksPath is "${r.hooksPath}". To enable auto-status,`);
|
|
147
|
+
log(' add `convene notify-push "$@"` to your existing pre-push hook.');
|
|
148
|
+
break;
|
|
149
|
+
case 'skipped-localhooks':
|
|
150
|
+
log(`· git pre-push hook skipped — you have hooks in ${r.hooksDir}; redirecting core.hooksPath`);
|
|
151
|
+
log(' would disable them. To enable auto-status, add `convene notify-push "$@"` to that pre-push hook.');
|
|
152
|
+
break;
|
|
153
|
+
case 'error':
|
|
154
|
+
log(`· git pre-push hook not installed (${r.message}).`);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function seedMemory(top, slug, baseUrl) {
|
|
159
|
+
try {
|
|
160
|
+
const mangled = top.replace(/\//g, '-');
|
|
161
|
+
const memDir = node_path_1.default.join((0, config_1.homeBase)(), '.claude', 'projects', mangled, 'memory');
|
|
162
|
+
node_fs_1.default.mkdirSync(memDir, { recursive: true });
|
|
163
|
+
const { name, content, indexLine } = (0, protocol_1.memoryEntry)(slug, baseUrl);
|
|
164
|
+
const memFile = node_path_1.default.join(memDir, `${name}.md`);
|
|
165
|
+
if (!node_fs_1.default.existsSync(memFile))
|
|
166
|
+
node_fs_1.default.writeFileSync(memFile, content);
|
|
167
|
+
const indexFile = node_path_1.default.join(memDir, 'MEMORY.md');
|
|
168
|
+
const idx = node_fs_1.default.existsSync(indexFile) ? node_fs_1.default.readFileSync(indexFile, 'utf8') : '# Memory Index\n\n';
|
|
169
|
+
if (!idx.includes(indexLine)) {
|
|
170
|
+
node_fs_1.default.writeFileSync(indexFile, (idx.endsWith('\n') ? idx : idx + '\n') + indexLine + '\n');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* memory seed is best-effort */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function init(opts) {
|
|
178
|
+
const top = (0, git_1.gitToplevel)();
|
|
179
|
+
if (!top)
|
|
180
|
+
(0, ctx_1.die)('not a git repository — run `convene init` inside a repo');
|
|
181
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
182
|
+
const baseUrl = cfg.baseUrl;
|
|
183
|
+
let member = cfg.member;
|
|
184
|
+
const existing = (0, config_1.loadProjectConfig)(top);
|
|
185
|
+
const skipHook = opts.noHook === true || opts.hook === false;
|
|
186
|
+
const skipGithook = opts.noGithook === true || opts.githook === false;
|
|
187
|
+
const skipJoinToken = opts.noJoinToken === true || opts.joinToken === false;
|
|
188
|
+
let slug = opts.slug || existing?.slug;
|
|
189
|
+
let displayName = opts.name || existing?.displayName;
|
|
190
|
+
let joinToken = existing?.joinToken; // reuse if present (keeps init idempotent)
|
|
191
|
+
if (!opts.offline) {
|
|
192
|
+
let apiKey = cfg.apiKey;
|
|
193
|
+
if (!apiKey) {
|
|
194
|
+
// Self-serve first run: provision a global identity + key — no seed, no
|
|
195
|
+
// owner-issued key. The new identity has ZERO memberships until this init
|
|
196
|
+
// creates a project (making me its owner).
|
|
197
|
+
const handle = (0, git_1.deriveHandle)(top);
|
|
198
|
+
const email = opts.email || (0, git_1.gitUserEmail)(top) || null;
|
|
199
|
+
if (!email) {
|
|
200
|
+
(0, ctx_1.die)('first-time setup needs an email to create your Convene identity — re-run with `--email you@example.com` (or `convene login --api-key -` if you already have a key).');
|
|
201
|
+
}
|
|
202
|
+
const anon = new api_1.ConveneApi(baseUrl, null, null, cfg.tool);
|
|
203
|
+
const prov = await anon.provision({ handle, email: email, tool: cfg.tool }, 8000);
|
|
204
|
+
if (prov.status === 409) {
|
|
205
|
+
(0, ctx_1.die)(`an identity already exists for handle "${handle}" / "${email}" — run \`convene login --api-key -\` with your existing key, then re-run init.`);
|
|
206
|
+
}
|
|
207
|
+
if (!prov.ok || !prov.json?.api_key)
|
|
208
|
+
(0, ctx_1.die)(`could not self-provision an identity: ${prov.error}`);
|
|
209
|
+
apiKey = prov.json.api_key;
|
|
210
|
+
member = prov.json.member?.handle ?? handle;
|
|
211
|
+
(0, config_1.saveFileConfig)({ apiKey, baseUrl, member: member ?? undefined });
|
|
212
|
+
log(`✓ provisioned identity "${member}" (key saved to ~/.convene/config.json, 0600)`);
|
|
213
|
+
}
|
|
214
|
+
const api = new api_1.ConveneApi(baseUrl, apiKey, member ? (0, git_1.sessionId)(member, top) : null, cfg.tool);
|
|
215
|
+
if (slug) {
|
|
216
|
+
const got = await api.getProject(slug, 8000);
|
|
217
|
+
if (got.status === 404) {
|
|
218
|
+
const created = await api.createProject({ slug, name: displayName || slug, repo_patterns: [(0, git_1.worktreeBasename)(top)] }, 8000);
|
|
219
|
+
if (!created.ok)
|
|
220
|
+
(0, ctx_1.die)(`could not create project ${slug}: ${created.error}`);
|
|
221
|
+
slug = created.json.project.slug;
|
|
222
|
+
displayName = displayName || created.json.project.name;
|
|
223
|
+
}
|
|
224
|
+
else if (got.status === 403) {
|
|
225
|
+
(0, ctx_1.die)(`project "${slug}" exists but you're not a member — run \`convene join\` (with a token) or ask an owner to add you`);
|
|
226
|
+
}
|
|
227
|
+
else if (!got.ok) {
|
|
228
|
+
(0, ctx_1.die)(`could not resolve project ${slug}: ${got.error}`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
displayName = displayName || got.json.project.name;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
slug = (0, git_1.worktreeBasename)(top).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
236
|
+
displayName = displayName || (0, git_1.worktreeBasename)(top);
|
|
237
|
+
const created = await api.createProject({ slug, name: displayName, repo_patterns: [(0, git_1.worktreeBasename)(top)] }, 8000);
|
|
238
|
+
if (created.status === 409) {
|
|
239
|
+
log(`Project "${slug}" already exists — attaching.`);
|
|
240
|
+
}
|
|
241
|
+
else if (!created.ok) {
|
|
242
|
+
(0, ctx_1.die)(`could not create project ${slug}: ${created.error}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Mint a self-serve join token (owner only) so teammates can `convene join`
|
|
246
|
+
// straight from the committed repo. Reused if one already exists (idempotent);
|
|
247
|
+
// skipped silently if we're not the owner (member attach) or --no-join-token.
|
|
248
|
+
if (!joinToken && !skipJoinToken) {
|
|
249
|
+
// Public-repo guard: never commit a join token to a repo we can CONFIRM is
|
|
250
|
+
// public — it would be a world-readable, self-serve credential to the bus.
|
|
251
|
+
const vis = await (0, git_1.repoIsPublic)(top);
|
|
252
|
+
if (vis === true && !opts.force) {
|
|
253
|
+
log('⚠ This repo appears to be PUBLIC — NOT committing a join token (it would be world-readable).');
|
|
254
|
+
log(' Writing a slug-only .convene/project.json. Distribute a token out of band, or, if you');
|
|
255
|
+
log(' really intend a committed token here, re-run with --force.');
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
if (vis === null && !opts.force) {
|
|
259
|
+
log('⚠ Could not verify repo visibility (no gh / non-GitHub / offline). Committing the join token,');
|
|
260
|
+
log(' assuming this repo is PRIVATE. If it is PUBLIC, re-run with `convene init --no-join-token`.');
|
|
261
|
+
}
|
|
262
|
+
const jt = await api.createJoinToken(slug, {}, 8000);
|
|
263
|
+
if (jt.ok && jt.json?.join_token)
|
|
264
|
+
joinToken = jt.json.join_token;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else if (!slug) {
|
|
269
|
+
(0, ctx_1.die)('--offline requires --slug (or an existing .convene/project.json)');
|
|
270
|
+
}
|
|
271
|
+
slug = slug;
|
|
272
|
+
displayName = displayName || slug;
|
|
273
|
+
// 3. .convene/project.json (committed). The joinToken is a PROJECT-SCOPED
|
|
274
|
+
// enrollment secret — safe to commit in a PRIVATE repo (repo-read = team).
|
|
275
|
+
const projFile = (0, config_1.writeProjectConfig)(top, { slug, displayName, ...(joinToken ? { joinToken } : {}) });
|
|
276
|
+
log(`✓ ${node_path_1.default.relative(top, projFile)}${joinToken ? ' (incl. join token — private repos only)' : ''}`);
|
|
277
|
+
// 4. .gitignore guard
|
|
278
|
+
ensureGitignoreGuard(top);
|
|
279
|
+
log('✓ .gitignore guard');
|
|
280
|
+
// 5. CLAUDE.md + AGENTS.md managed block
|
|
281
|
+
const block = (0, protocol_1.conveneBlock)(slug, member, baseUrl);
|
|
282
|
+
for (const fname of ['CLAUDE.md', 'AGENTS.md']) {
|
|
283
|
+
const file = node_path_1.default.join(top, fname);
|
|
284
|
+
const old = node_fs_1.default.existsSync(file) ? node_fs_1.default.readFileSync(file, 'utf8') : '';
|
|
285
|
+
const result = writeIfChanged(file, upsertMarkerBlock(old, block));
|
|
286
|
+
log(`${result === 'unchanged' ? '·' : '✓'} ${fname} (${result})`);
|
|
287
|
+
}
|
|
288
|
+
// 6. portable protocol doc — write only if ABSENT (mirrors the memory-seed
|
|
289
|
+
// pattern). The doc is hand-enrichable; unconditionally overwriting it with
|
|
290
|
+
// the generated stub would clobber any teammate's expanded protocol spec.
|
|
291
|
+
const protoFile = node_path_1.default.join(top, 'CONVENE_PROTOCOL.md');
|
|
292
|
+
if (!node_fs_1.default.existsSync(protoFile)) {
|
|
293
|
+
node_fs_1.default.writeFileSync(protoFile, (0, protocol_1.protocolDoc)(slug, baseUrl));
|
|
294
|
+
log('✓ CONVENE_PROTOCOL.md (created)');
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
log('· CONVENE_PROTOCOL.md (exists — left as-is)');
|
|
298
|
+
}
|
|
299
|
+
// 7. hook
|
|
300
|
+
registerHook(skipHook);
|
|
301
|
+
// 7b. committed git pre-push hook — the tool-agnostic backstop that auto-posts a
|
|
302
|
+
// [STATUS] when work is pushed (fires for Codex/Cowork/humans, not just Claude).
|
|
303
|
+
installGitHookStep(top, skipGithook);
|
|
304
|
+
// 8. memory seed (best-effort, outside the repo)
|
|
305
|
+
seedMemory(top, slug, baseUrl);
|
|
306
|
+
// 9. teammate one-liner
|
|
307
|
+
log('');
|
|
308
|
+
log(`Done. Project "${slug}" — dashboard: ${baseUrl}/p/${slug}`);
|
|
309
|
+
if (joinToken) {
|
|
310
|
+
log('Teammates (after they have the convene CLI) just run, from this repo:');
|
|
311
|
+
log(` ${brand_1.BRAND.bin} join`);
|
|
312
|
+
log(' …which self-provisions them from the committed join token. Commit .convene/project.json.');
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
log('Teammate onboarding:');
|
|
316
|
+
log(` ${brand_1.BRAND.bin} login --api-key - # paste a cvk_ key issued by an owner`);
|
|
317
|
+
log(` ${brand_1.BRAND.bin} init --slug ${slug}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.join = join;
|
|
4
|
+
/**
|
|
5
|
+
* `convene join` — self-provision / plug in. The single verb for both cases:
|
|
6
|
+
* - Not logged in yet → redeems the token, CREATES your identity + key.
|
|
7
|
+
* - Already logged in → redeems the token to ADD your existing identity to
|
|
8
|
+
* this project (no new key). Idempotent if already a member.
|
|
9
|
+
* Then registers the per-prompt hook. Token + handle/email default from
|
|
10
|
+
* .convene/project.json and git config.
|
|
11
|
+
*/
|
|
12
|
+
const brand_1 = require("../brand");
|
|
13
|
+
const api_1 = require("../api");
|
|
14
|
+
const config_1 = require("../config");
|
|
15
|
+
const git_1 = require("../git");
|
|
16
|
+
const hook_1 = require("../hook");
|
|
17
|
+
const githook_1 = require("../githook");
|
|
18
|
+
const ctx_1 = require("../ctx");
|
|
19
|
+
const log = (m) => process.stdout.write(m + '\n');
|
|
20
|
+
async function join(opts) {
|
|
21
|
+
const top = (0, git_1.gitToplevel)();
|
|
22
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
23
|
+
const slug = opts.slug || proj?.slug;
|
|
24
|
+
if (!slug)
|
|
25
|
+
(0, ctx_1.die)('no project — run inside a `convene init`-ed repo, or pass --slug <slug>');
|
|
26
|
+
const token = opts.token || process.env.CONVENE_JOIN_TOKEN || proj?.joinToken;
|
|
27
|
+
if (!token)
|
|
28
|
+
(0, ctx_1.die)('no join token — pass --token <cvj_…>, set CONVENE_JOIN_TOKEN, or ask an owner for one');
|
|
29
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
30
|
+
const baseUrl = (opts.baseUrl || cfg.baseUrl).replace(/\/$/, '');
|
|
31
|
+
const handle = opts.handle || (0, git_1.deriveHandle)(top ?? undefined);
|
|
32
|
+
const email = opts.email || (0, git_1.gitUserEmail)(top ?? undefined) || undefined;
|
|
33
|
+
// Authenticated mode if we already have a key (adds existing identity);
|
|
34
|
+
// unauthenticated mode otherwise (creates a new identity + key).
|
|
35
|
+
const api = new api_1.ConveneApi(baseUrl, cfg.apiKey ?? null);
|
|
36
|
+
const res = await api.join(slug, { token, handle, email, display_name: handle, tool: 'cli' }, 10_000);
|
|
37
|
+
if (res.status === 409)
|
|
38
|
+
(0, ctx_1.die)(`the handle "${handle}" is already taken — re-run with --handle <unique-handle>`);
|
|
39
|
+
if (res.status === 401 && cfg.apiKey)
|
|
40
|
+
(0, ctx_1.die)('join token is invalid/expired/revoked, or your saved key is bad — ask an owner');
|
|
41
|
+
if (res.status === 401)
|
|
42
|
+
(0, ctx_1.die)('join token is invalid, expired, or revoked — ask an owner for a fresh one');
|
|
43
|
+
if (res.status === 403)
|
|
44
|
+
(0, ctx_1.die)(res.error || 'cannot join: your account is in a different org');
|
|
45
|
+
if (!res.ok && res.status !== 200)
|
|
46
|
+
(0, ctx_1.die)(`join failed (${res.status}): ${res.error ?? 'unknown error'}`);
|
|
47
|
+
const memberHandle = res.json?.member?.handle ?? handle;
|
|
48
|
+
if (res.json?.api_key) {
|
|
49
|
+
// New identity created — save the key.
|
|
50
|
+
(0, config_1.saveFileConfig)({ apiKey: res.json.api_key, baseUrl, member: memberHandle });
|
|
51
|
+
log(`Joined "${slug}" as ${memberHandle} (new identity). Logged in (config 0600).`);
|
|
52
|
+
}
|
|
53
|
+
else if (res.json?.already) {
|
|
54
|
+
log(`Already a member of "${slug}" as ${memberHandle}. You're operational.`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
log(`Added to "${slug}" as ${memberHandle}.`);
|
|
58
|
+
}
|
|
59
|
+
const hook = (0, hook_1.ensureHookRegistered)();
|
|
60
|
+
log(hook === 'registered'
|
|
61
|
+
? 'Registered the convene fetch hook in ~/.claude/settings.json.'
|
|
62
|
+
: hook === 'already'
|
|
63
|
+
? 'Hook already registered.'
|
|
64
|
+
: 'Could not auto-register the hook — run `convene doctor --fix` or add it manually.');
|
|
65
|
+
// The tool-agnostic backstop: auto-post a [STATUS] when you push (works even
|
|
66
|
+
// for tools without a per-prompt hook).
|
|
67
|
+
if (top) {
|
|
68
|
+
const gh = (0, githook_1.installGitHooks)(top);
|
|
69
|
+
if (gh.status === 'installed' || gh.status === 'updated') {
|
|
70
|
+
log('Installed the git pre-push hook (.githooks/pre-push) — pushes auto-post a [STATUS].');
|
|
71
|
+
}
|
|
72
|
+
else if (gh.status === 'skipped-foreign') {
|
|
73
|
+
log(`Git pre-push hook skipped — core.hooksPath is "${gh.hooksPath}"; add \`convene notify-push "$@"\` to it.`);
|
|
74
|
+
}
|
|
75
|
+
else if (gh.status === 'skipped-localhooks') {
|
|
76
|
+
log(`Git pre-push hook skipped — existing hooks in ${gh.hooksDir}; add \`convene notify-push "$@"\` to your pre-push.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
log(`You are operational on ${brand_1.BRAND.product}. Try: convene whoami`);
|
|
80
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.migrate = migrate;
|
|
4
|
+
/**
|
|
5
|
+
* `convene migrate` — Observability cutover helper. Runs `init` for this repo,
|
|
6
|
+
* then prints the cutover reminders. The full, ordered runbook lives in
|
|
7
|
+
* MIGRATION_OBSERVABILITY.md (it is a deliverable, not executed automatically).
|
|
8
|
+
*/
|
|
9
|
+
const init_1 = require("./init");
|
|
10
|
+
const log = (m) => process.stdout.write(m + '\n');
|
|
11
|
+
async function migrate(opts) {
|
|
12
|
+
log('Convene migration helper — cutting this repo over to Convene.');
|
|
13
|
+
log('Full ordered runbook: MIGRATION_OBSERVABILITY.md\n');
|
|
14
|
+
await (0, init_1.init)(opts);
|
|
15
|
+
log('');
|
|
16
|
+
log('Cutover reminders (see the runbook for exact commands):');
|
|
17
|
+
log(' 1. Replace any old `coord-fetch.sh` UserPromptSubmit hook in ~/.claude/settings.json with `convene fetch`.');
|
|
18
|
+
log(' 2. Remove legacy Slack / PowerShell / curl coordination hooks.');
|
|
19
|
+
log(' 3. Retire coord-identity / coord-repos / coord.env and ROTATE the old Slack token.');
|
|
20
|
+
log(' 4. Humans use the Convene dashboard as the backstop view.');
|
|
21
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parsePrePush = parsePrePush;
|
|
4
|
+
exports.summarizePush = summarizePush;
|
|
5
|
+
exports.idemKey = idemKey;
|
|
6
|
+
exports.notifyPush = notifyPush;
|
|
7
|
+
/**
|
|
8
|
+
* `convene notify-push` — the git pre-push hook's bus poster. Fail-OPEN like
|
|
9
|
+
* `convene fetch` (P0-FAILSAFE): any error, missing config, or non-bus repo exits
|
|
10
|
+
* 0 silently, so it can NEVER block a push. Every path force-exits via done() (a
|
|
11
|
+
* lingering keep-alive/abort socket must not extend the process and stall the
|
|
12
|
+
* push); a 5s watchdog backstops anything that still hangs in async code.
|
|
13
|
+
*
|
|
14
|
+
* It reads git's pre-push payload from stdin — one line per pushed ref,
|
|
15
|
+
* `<local_ref> <local_sha> <remote_ref> <remote_sha>` — to summarize exactly what
|
|
16
|
+
* is being pushed, and posts ONE [STATUS] with a DETERMINISTIC idempotency key
|
|
17
|
+
* keyed on the (ref, local sha, remote sha) tuples, so a retried/identical push
|
|
18
|
+
* dedupes while genuinely new work (a new branch, a second remote, a force-push)
|
|
19
|
+
* always posts.
|
|
20
|
+
*/
|
|
21
|
+
const config_1 = require("../config");
|
|
22
|
+
const git_1 = require("../git");
|
|
23
|
+
const api_1 = require("../api");
|
|
24
|
+
const isZero = (sha) => /^0+$/.test(sha);
|
|
25
|
+
/** Parse git's pre-push stdin into the non-deletion refs being pushed. */
|
|
26
|
+
function parsePrePush(stdin) {
|
|
27
|
+
const refs = [];
|
|
28
|
+
for (const line of stdin.split('\n')) {
|
|
29
|
+
const parts = line.trim().split(/\s+/);
|
|
30
|
+
if (parts.length < 4)
|
|
31
|
+
continue;
|
|
32
|
+
const [localRef, localSha, , remoteSha] = parts;
|
|
33
|
+
if (!localSha || isZero(localSha))
|
|
34
|
+
continue; // branch/tag deletion — nothing landed
|
|
35
|
+
const isTag = localRef.startsWith('refs/tags/');
|
|
36
|
+
const branch = isTag
|
|
37
|
+
? `tag:${localRef.replace(/^refs\/tags\//, '')}`
|
|
38
|
+
: localRef.replace(/^refs\/heads\//, '');
|
|
39
|
+
refs.push({ branch, localSha, remoteSha: remoteSha || '', isNew: !remoteSha || isZero(remoteSha), isTag });
|
|
40
|
+
}
|
|
41
|
+
return refs;
|
|
42
|
+
}
|
|
43
|
+
function clip(s, max) {
|
|
44
|
+
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
|
45
|
+
}
|
|
46
|
+
/** A one-line human summary of the push (the [STATUS] body). */
|
|
47
|
+
function summarizePush(refs, cwd) {
|
|
48
|
+
const r = refs[0];
|
|
49
|
+
const subject = (0, git_1.commitSubject)(r.localSha, cwd) || '(no commit message)';
|
|
50
|
+
const short = (0, git_1.shortSha)(r.localSha, cwd) || r.localSha.slice(0, 7);
|
|
51
|
+
let head;
|
|
52
|
+
if (r.isTag) {
|
|
53
|
+
head = `pushed ${r.branch.replace(/^tag:/, 'tag ')}`;
|
|
54
|
+
}
|
|
55
|
+
else if (r.isNew) {
|
|
56
|
+
head = `pushed new branch ${r.branch}`;
|
|
57
|
+
}
|
|
58
|
+
else if (!r.remoteSha) {
|
|
59
|
+
head = `pushed ${r.branch}`; // remote tip unknown (manual run)
|
|
60
|
+
}
|
|
61
|
+
else if (!(0, git_1.isAncestor)(r.remoteSha, r.localSha, cwd)) {
|
|
62
|
+
head = `force-pushed ${r.branch}`; // history rewritten — a plain count would mislead
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const n = (0, git_1.revListCount)(`${r.remoteSha}..${r.localSha}`, cwd);
|
|
66
|
+
head = n && n > 0 ? `pushed ${n} commit${n === 1 ? '' : 's'} to ${r.branch}` : `pushed to ${r.branch}`;
|
|
67
|
+
}
|
|
68
|
+
let body = `${head} · ${subject} (${short})`;
|
|
69
|
+
const more = refs.length - 1;
|
|
70
|
+
if (more > 0)
|
|
71
|
+
body += ` (+${more} more ref${more === 1 ? '' : 's'})`;
|
|
72
|
+
return clip(body, 200);
|
|
73
|
+
}
|
|
74
|
+
/** Deterministic dedupe key: the full (ref, local, remote) tuples, order-independent. */
|
|
75
|
+
function idemKey(slug, refs) {
|
|
76
|
+
return `push:${slug}:${refs.map((r) => `${r.branch}@${r.localSha}<-${r.remoteSha}`).sort().join(',')}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read git's pre-push payload from stdin — ASYNCHRONOUSLY and timeout-bounded, so
|
|
80
|
+
* a stdin that never closes (an idle pipe held open by a wrapper) can't wedge the
|
|
81
|
+
* event loop the way a synchronous readFileSync(0) would. git writes the payload
|
|
82
|
+
* and closes the pipe immediately, so 'end' normally fires in well under a
|
|
83
|
+
* millisecond; the timeout only matters for pathological callers. A TTY (manual
|
|
84
|
+
* run) reads nothing and falls through to the HEAD summary.
|
|
85
|
+
*/
|
|
86
|
+
function readStdin(timeoutMs) {
|
|
87
|
+
if (process.stdin.isTTY)
|
|
88
|
+
return Promise.resolve(null);
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let data = '';
|
|
91
|
+
let settled = false;
|
|
92
|
+
const finish = (v) => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
settled = true;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
process.stdin.removeAllListeners();
|
|
98
|
+
resolve(v);
|
|
99
|
+
};
|
|
100
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
101
|
+
process.stdin.setEncoding('utf8');
|
|
102
|
+
process.stdin.on('data', (c) => {
|
|
103
|
+
data += c;
|
|
104
|
+
});
|
|
105
|
+
process.stdin.on('end', () => finish(data));
|
|
106
|
+
process.stdin.on('error', () => finish(null));
|
|
107
|
+
process.stdin.resume();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async function run(opts) {
|
|
111
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
112
|
+
const top = (0, git_1.gitToplevel)();
|
|
113
|
+
const cwd = top || process.cwd();
|
|
114
|
+
const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
|
|
115
|
+
if (!slug)
|
|
116
|
+
return; // not a bus repo — silent no-op, exactly like `convene fetch`
|
|
117
|
+
const stdin = await readStdin(1500);
|
|
118
|
+
let refs;
|
|
119
|
+
if (stdin !== null) {
|
|
120
|
+
// Invoked by git: trust the payload. No non-deletion refs ⇒ nothing landed ⇒ no-op.
|
|
121
|
+
refs = parsePrePush(stdin);
|
|
122
|
+
if (!refs.length)
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
// Manual run with no pipe — synthesize a ref from HEAD as a convenience.
|
|
127
|
+
const localSha = (0, git_1.revParse)('HEAD', cwd);
|
|
128
|
+
if (!localSha)
|
|
129
|
+
return; // nothing meaningful to say
|
|
130
|
+
refs = [{ branch: (0, git_1.currentBranch)(cwd) || 'HEAD', localSha, remoteSha: '', isNew: false, isTag: false }];
|
|
131
|
+
}
|
|
132
|
+
const body = summarizePush(refs, cwd);
|
|
133
|
+
const idem = idemKey(slug, refs);
|
|
134
|
+
if (opts.dryRun) {
|
|
135
|
+
process.stdout.write(body + '\n');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Credentials are only needed for the real post (dry-run works offline).
|
|
139
|
+
if (!cfg.apiKey || !cfg.member)
|
|
140
|
+
return;
|
|
141
|
+
const session = top ? (0, git_1.sessionId)(cfg.member, top) : `${cfg.member}/cli`;
|
|
142
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool);
|
|
143
|
+
const res = await api.post(slug, { type: 'status', body }, idem, 4000);
|
|
144
|
+
if (res.ok && res.json?.message?.short_id) {
|
|
145
|
+
process.stdout.write(`convene: posted [STATUS] ${res.json.message.short_id} — ${body}\n`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function notifyPush(opts) {
|
|
149
|
+
// Backstop only: every path below force-exits via done(), so the process never
|
|
150
|
+
// lingers on a keep-alive/orphaned socket and stalls the push. The watchdog
|
|
151
|
+
// catches anything that hangs in async code despite that.
|
|
152
|
+
const watchdog = setTimeout(() => process.exit(0), 5000);
|
|
153
|
+
const done = () => {
|
|
154
|
+
clearTimeout(watchdog);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
};
|
|
157
|
+
try {
|
|
158
|
+
await run(opts);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* fail-open: a coordination post must never break a push */
|
|
162
|
+
}
|
|
163
|
+
done();
|
|
164
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolve = exports.decline = exports.accept = exports.ack = void 0;
|
|
4
|
+
exports.postStatus = postStatus;
|
|
5
|
+
exports.postQuestion = postQuestion;
|
|
6
|
+
exports.postPropose = postPropose;
|
|
7
|
+
exports.answer = answer;
|
|
8
|
+
/**
|
|
9
|
+
* Outbound + interactive verbs. Unlike `fetch`, these are NON-silent: on failure
|
|
10
|
+
* they print to stderr and exit non-zero so the agent tells the human.
|
|
11
|
+
*/
|
|
12
|
+
const ctx_1 = require("../ctx");
|
|
13
|
+
async function send(slug, body) {
|
|
14
|
+
const ctx = (0, ctx_1.getContext)({ project: slug === '__cwd__' ? undefined : slug });
|
|
15
|
+
const realSlug = (0, ctx_1.requireSlug)(ctx);
|
|
16
|
+
const res = await ctx.api.post(realSlug, body, (0, ctx_1.uuid)());
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
(0, ctx_1.die)(`post failed (${res.status}): ${res.error ?? 'unknown error'}`);
|
|
19
|
+
return res.json?.message;
|
|
20
|
+
}
|
|
21
|
+
async function postStatus(body, opts) {
|
|
22
|
+
const m = await send(opts.project ?? '__cwd__', { type: 'status', body });
|
|
23
|
+
process.stdout.write(`posted [STATUS] ${m.short_id}\n`);
|
|
24
|
+
}
|
|
25
|
+
async function postQuestion(body, opts) {
|
|
26
|
+
// Pass `to` through as-is (undefined when omitted => dropped by JSON.stringify
|
|
27
|
+
// => "anyone"); never coerce to null.
|
|
28
|
+
const m = await send(opts.project ?? '__cwd__', { type: 'question', body, to: opts.to });
|
|
29
|
+
process.stdout.write(`posted [QUESTION] ${m.short_id}${opts.to ? ` to ${opts.to}` : ' (anyone)'}\n`);
|
|
30
|
+
}
|
|
31
|
+
async function postPropose(opts) {
|
|
32
|
+
if (!opts.to)
|
|
33
|
+
(0, ctx_1.die)('propose requires --to <member>');
|
|
34
|
+
if (!opts.prompt)
|
|
35
|
+
(0, ctx_1.die)('propose requires --prompt "<literal next prompt>"');
|
|
36
|
+
const m = await send(opts.project ?? '__cwd__', {
|
|
37
|
+
type: 'propose_prompt',
|
|
38
|
+
to: opts.to,
|
|
39
|
+
to_session_glob: opts.sessionGlob,
|
|
40
|
+
context: opts.context,
|
|
41
|
+
prompt: opts.prompt,
|
|
42
|
+
});
|
|
43
|
+
process.stdout.write(`posted [PROPOSE-PROMPT] ${m.short_id} to ${opts.to}\n`);
|
|
44
|
+
}
|
|
45
|
+
async function answer(id, body, opts) {
|
|
46
|
+
const m = await send(opts.project ?? '__cwd__', { type: 'answer', in_reply_to: id, body });
|
|
47
|
+
process.stdout.write(`answered ${id} (${m.short_id})\n`);
|
|
48
|
+
}
|
|
49
|
+
async function transition(id, action, body) {
|
|
50
|
+
const ctx = (0, ctx_1.getContext)();
|
|
51
|
+
const res = await ctx.api.transition(id, action, body);
|
|
52
|
+
if (res.status === 409)
|
|
53
|
+
(0, ctx_1.die)(`already closed — ${id} was resolved by someone else`);
|
|
54
|
+
if (!res.ok)
|
|
55
|
+
(0, ctx_1.die)(`${action} failed (${res.status}): ${res.error ?? 'unknown error'}`);
|
|
56
|
+
process.stdout.write(`${action}d ${id}\n`);
|
|
57
|
+
}
|
|
58
|
+
const ack = (id) => transition(id, 'ack');
|
|
59
|
+
exports.ack = ack;
|
|
60
|
+
const accept = (id) => transition(id, 'accept');
|
|
61
|
+
exports.accept = accept;
|
|
62
|
+
const decline = (id, opts) => transition(id, 'decline', { reason: opts.reason });
|
|
63
|
+
exports.decline = decline;
|
|
64
|
+
const resolve = (id) => transition(id, 'resolve');
|
|
65
|
+
exports.resolve = resolve;
|