great-cto 3.70.0 → 3.71.1

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.
Files changed (37) hide show
  1. package/README.md +9 -1
  2. package/board/.claude-plugin/plugin.json +93 -1
  3. package/board/packages/board/lib/routes.mjs +14 -1
  4. package/board/packages/board/lib/work.mjs +34 -3
  5. package/board/packages/board/public/assets/admin-shell.css +63 -0
  6. package/board/packages/board/public/assets/admin-shell.js +39 -5
  7. package/board/packages/board/public/assets/work-view.js +320 -37
  8. package/board/packages/board/public/index.html +43 -1
  9. package/board/scripts/codex-pipeline.mjs +7 -1
  10. package/board/scripts/lib/claude-exec.mjs +49 -6
  11. package/board/scripts/lib/codex-exec.mjs +42 -18
  12. package/board/scripts/lib/codex-host-state.mjs +18 -2
  13. package/board/scripts/lib/codex-pipeline.mjs +44 -7
  14. package/board/scripts/lib/host-graph-bindings.mjs +135 -0
  15. package/board/scripts/lib/host-graph-events.mjs +157 -0
  16. package/board/scripts/lib/host-graph-journal.mjs +210 -0
  17. package/board/scripts/lib/host-graph-observer.mjs +61 -0
  18. package/board/scripts/lib/host-graph-runtime.mjs +38 -0
  19. package/board/scripts/work-task.mjs +17 -4
  20. package/dist/adapt.js +10 -21
  21. package/dist/codex.js +4 -15
  22. package/dist/companion.js +6 -122
  23. package/dist/installer.js +4 -72
  24. package/dist/main.js +51 -118
  25. package/dist/release-activation.mjs +561 -0
  26. package/dist/release-archive.mjs +165 -0
  27. package/dist/release-trust.mjs +514 -0
  28. package/dist/release-update.mjs +303 -0
  29. package/dist/self-upgrade.js +10 -39
  30. package/dist/update-check.js +1 -0
  31. package/dist/upgrade.js +5 -120
  32. package/package.json +1 -1
  33. package/board/packages/board/fixtures/beads-read-owner.mjs +0 -3
  34. package/board/packages/board/fixtures/fake-bd-controlled.mjs +0 -55
  35. package/board/packages/board/fixtures/fake-bd-fail.sh +0 -9
  36. package/board/packages/board/fixtures/fake-bd-slow.sh +0 -12
  37. package/board/packages/board/fixtures/held-receipt-git.mjs +0 -21
@@ -0,0 +1,303 @@
1
+ /** Trusted consumer orchestration. Downloaded bytes cannot choose policy or tools. */
2
+ import fs from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { admitRelease, parseStrictReleaseIndex, readTrustedLocalPublicKey } from './release-trust.mjs';
7
+ import { activateNpmRelease, resolveNpmInstallation } from './release-activation.mjs';
8
+ const REPOSITORY = 'avelikiy/great_cto';
9
+ const API = `https://api.github.com/repos/${REPOSITORY}/releases/latest`;
10
+ const VERSION = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
11
+ const HASH = /^[a-f0-9]{64}$/;
12
+ const REDIRECT_HOSTS = new Set(['release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'github-releases.githubusercontent.com']);
13
+ const CAPS = { index: 256 * 1024, bundle: 2 * 1024 * 1024, archive: 128 * 1024 * 1024 };
14
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
15
+ const stateHome = () => path.join(homedir(), '.great_cto', 'distribution-trust');
16
+ function stableVersion(value) {
17
+ if (typeof value !== 'string' || !VERSION.test(value))
18
+ throw Error('invalid exact stable release version');
19
+ return value;
20
+ }
21
+ function compare(a, b) {
22
+ const x = stableVersion(a).split('.').map(Number), y = stableVersion(b).split('.').map(Number);
23
+ for (let i = 0; i < 3; i++)
24
+ if (x[i] !== y[i])
25
+ return x[i] - y[i];
26
+ return 0;
27
+ }
28
+ function canonicalDirectory(dir, create = false) {
29
+ if (!path.isAbsolute(dir))
30
+ throw Error('state directory must be absolute');
31
+ if (create)
32
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
33
+ if (fs.realpathSync(dir) !== path.resolve(dir))
34
+ throw Error('state directory must be canonical');
35
+ const s = fs.lstatSync(dir);
36
+ if (!s.isDirectory() || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid()))
37
+ throw Error('state directory must be private and owned');
38
+ return dir;
39
+ }
40
+ function regular(file, max, { privateFile = false } = {}) {
41
+ if (!path.isAbsolute(file) || fs.realpathSync(file) !== path.resolve(file))
42
+ throw Error('toolchain file must be canonical');
43
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
44
+ try {
45
+ const s = fs.fstatSync(fd);
46
+ if (!s.isFile() || s.nlink !== 1 || s.size > max || (s.mode & (privateFile ? 0o077 : 0o022)) || (process.getuid && s.uid !== process.getuid() && s.uid !== 0))
47
+ throw Error('unsafe toolchain file');
48
+ return fs.readFileSync(fd);
49
+ }
50
+ finally {
51
+ fs.closeSync(fd);
52
+ }
53
+ }
54
+ function executable(file, expected) {
55
+ if (!HASH.test(expected) || digest(regular(file, 256 * 1024 * 1024)) !== expected)
56
+ throw Error('trusted tool digest mismatch');
57
+ fs.accessSync(file, fs.constants.X_OK);
58
+ }
59
+ function exactKeys(value, keys) {
60
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join(',') !== [...keys].sort().join(','))
61
+ throw Error('invalid operator toolchain schema');
62
+ }
63
+ function checkedToolchain(config) {
64
+ if (config?.schema === 2) {
65
+ exactKeys(config, ['schema', 'profile', 'publicKey', 'npm']);
66
+ if (config.profile !== 'local-ed25519')
67
+ throw Error('unsupported operator trust profile');
68
+ exactKeys(config.publicKey, ['path', 'fingerprint']);
69
+ exactKeys(config.npm, ['path', 'sha256']);
70
+ readTrustedLocalPublicKey(config.publicKey.path, config.publicKey.fingerprint);
71
+ executable(config.npm.path, config.npm.sha256);
72
+ return config;
73
+ }
74
+ exactKeys(config, ['schema', 'cosign', 'trustedRoot', 'npm']);
75
+ if (config.schema !== 1)
76
+ throw Error('unsupported operator toolchain schema');
77
+ for (const tool of [config.cosign, config.trustedRoot, config.npm]) {
78
+ exactKeys(tool, ['path', 'sha256']);
79
+ if (typeof tool.path !== 'string' || !HASH.test(tool.sha256))
80
+ throw Error('invalid operator toolchain pin');
81
+ }
82
+ executable(config.cosign.path, config.cosign.sha256);
83
+ executable(config.npm.path, config.npm.sha256);
84
+ if (digest(regular(config.trustedRoot.path, CAPS.bundle)) !== config.trustedRoot.sha256)
85
+ throw Error('trusted root digest mismatch');
86
+ return config;
87
+ }
88
+ /** Registration is explicit operator trust, not authentication of a release. */
89
+ export function configureTrustedToolchain(config, { stateRoot = stateHome(), acknowledged = false } = {}) {
90
+ if (!acknowledged)
91
+ throw Error('independent toolchain acknowledgement required');
92
+ checkedToolchain(config);
93
+ canonicalDirectory(stateRoot, true);
94
+ const file = path.join(stateRoot, 'toolchain.json');
95
+ if (fs.existsSync(file))
96
+ throw Error('toolchain already configured; explicit reviewed rotation is required');
97
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
98
+ try {
99
+ fs.writeFileSync(fd, JSON.stringify(config, null, 2) + '\n');
100
+ fs.fsyncSync(fd);
101
+ }
102
+ finally {
103
+ fs.closeSync(fd);
104
+ }
105
+ const dir = fs.openSync(stateRoot, fs.constants.O_RDONLY);
106
+ try {
107
+ fs.fsyncSync(dir);
108
+ }
109
+ finally {
110
+ fs.closeSync(dir);
111
+ }
112
+ return { state: 'TOOLCHAIN_CONFIGURED', authority: 'operator-toolchain-only', releaseEligibility: 'NOT_CHECKED' };
113
+ }
114
+ export function readTrustedToolchain({ stateRoot = stateHome() } = {}) {
115
+ canonicalDirectory(stateRoot);
116
+ return checkedToolchain(JSON.parse(regular(path.join(stateRoot, 'toolchain.json'), 8192, { privateFile: true }).toString('utf8')));
117
+ }
118
+ /** Validate every redirect. No release-supplied URL or bearer token is accepted. */
119
+ export function allowedDownloadURL(value, initial) {
120
+ const url = new URL(value), origin = new URL(initial);
121
+ const api = origin.href === API;
122
+ const parts = origin.pathname.match(/^\/avelikiy\/great_cto\/releases\/download\/v([^/]+)\/([^/]+)$/);
123
+ const release = origin.hostname === 'github.com' && !origin.search && parts && VERSION.test(parts[1])
124
+ && ['release-index.json', 'plugin-inventory.json', 'sbom.cdx.json', `great-cto-${parts[1]}.tgz`, `great-cto-plugin-${parts[1]}.tgz`]
125
+ .flatMap(name => [name, name + '.sigstore.json', name + '.signature.json']).includes(parts[2]);
126
+ if (!api && !release)
127
+ throw Error('refused initial release origin or asset');
128
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || url.hash)
129
+ throw Error('refused release download URL');
130
+ if (url.href === origin.href)
131
+ return url.href;
132
+ if (!REDIRECT_HOSTS.has(url.hostname))
133
+ throw Error('refused release redirect origin');
134
+ return url.href;
135
+ }
136
+ export async function readBoundedURL(initial, limit, { fetchFn = fetch, signal = AbortSignal.timeout(180000) } = {}) {
137
+ let next = initial;
138
+ for (let redirects = 0; redirects <= 4; redirects++) {
139
+ allowedDownloadURL(next, initial);
140
+ const response = await fetchFn(next, { redirect: 'manual', signal, headers: { 'user-agent': 'great-cto-verified-update', accept: initial === API ? 'application/vnd.github+json' : 'application/octet-stream', 'accept-encoding': 'identity' } });
141
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
142
+ const location = response.headers.get('location');
143
+ await response.body?.cancel();
144
+ if (!location)
145
+ throw Error('release redirect without location');
146
+ next = allowedDownloadURL(new URL(location, next).href, initial);
147
+ continue;
148
+ }
149
+ if (!response.ok || !response.body)
150
+ throw Error('release download unavailable');
151
+ // Fetch decodes compressed responses. Enforce the decoded cap below, but
152
+ // compare Content-Length only when it describes those same identity bytes.
153
+ const encoding = response.headers.get('content-encoding')?.trim().toLowerCase();
154
+ if (encoding && !['identity', 'gzip', 'br', 'deflate'].includes(encoding)) {
155
+ await response.body.cancel();
156
+ throw Error('unsupported release content encoding');
157
+ }
158
+ const length = response.headers.get('content-length');
159
+ if (length !== null && (!/^\d+$/.test(length) || Number(length) > limit)) {
160
+ await response.body.cancel();
161
+ throw Error('oversized release download');
162
+ }
163
+ const chunks = [];
164
+ let size = 0;
165
+ const reader = response.body.getReader();
166
+ try {
167
+ while (true) {
168
+ const { done, value } = await reader.read();
169
+ if (done)
170
+ break;
171
+ size += value.length;
172
+ if (size > limit) {
173
+ await reader.cancel();
174
+ throw Error('oversized release download');
175
+ }
176
+ chunks.push(Buffer.from(value));
177
+ }
178
+ }
179
+ finally {
180
+ reader.releaseLock();
181
+ }
182
+ if (length !== null && (!encoding || encoding === 'identity') && size !== Number(length))
183
+ throw Error('truncated release download');
184
+ return Buffer.concat(chunks);
185
+ }
186
+ throw Error('release redirect limit exceeded');
187
+ }
188
+ export async function discoverReleaseVersion(options = {}) {
189
+ const value = JSON.parse((await readBoundedURL(API, CAPS.index, options)).toString('utf8'));
190
+ if (value.draft !== false || value.prerelease !== false || typeof value.tag_name !== 'string' || !value.tag_name.startsWith('v'))
191
+ throw Error('no exact stable published release');
192
+ return stableVersion(value.tag_name.slice(1));
193
+ }
194
+ function storeFile(dir, name, bytes) {
195
+ const fd = fs.openSync(path.join(dir, name), fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
196
+ try {
197
+ fs.writeFileSync(fd, bytes);
198
+ fs.fsyncSync(fd);
199
+ }
200
+ finally {
201
+ fs.closeSync(fd);
202
+ }
203
+ }
204
+ export async function stageRelease(version, { stateRoot = stateHome(), fetchFn = fetch, profile = 'github-oidc' } = {}) {
205
+ stableVersion(version);
206
+ if (!['github-oidc', 'local-ed25519'].includes(profile))
207
+ throw Error('unsupported release trust profile');
208
+ const suffix = profile === 'local-ed25519' ? '.signature.json' : '.sigstore.json';
209
+ canonicalDirectory(stateRoot, true);
210
+ const parent = canonicalDirectory(path.join(stateRoot, 'staging'), true);
211
+ const directory = fs.realpathSync(fs.mkdtempSync(path.join(parent, 'candidate-')));
212
+ const base = `https://github.com/${REPOSITORY}/releases/download/v${version}/`;
213
+ const signal = AbortSignal.timeout(180000);
214
+ const get = (name, cap) => readBoundedURL(base + name, cap, { fetchFn, signal });
215
+ try {
216
+ const index = await get('release-index.json', CAPS.index);
217
+ const parsed = parseStrictReleaseIndex(index.toString('utf8'));
218
+ if (parsed.version !== version)
219
+ throw Error('release index version mismatch');
220
+ storeFile(directory, 'release-index.json', index);
221
+ storeFile(directory, 'release-index.json' + suffix, await get('release-index.json' + suffix, CAPS.bundle));
222
+ // Strict validation fixes all artifact basenames before they reach a URL.
223
+ const names = [
224
+ [`great-cto-${version}.tgz`, CAPS.archive], [`great-cto-plugin-${version}.tgz`, CAPS.archive],
225
+ ['plugin-inventory.json', CAPS.bundle], ['sbom.cdx.json', CAPS.bundle],
226
+ ];
227
+ for (const [name, cap] of names) {
228
+ storeFile(directory, name, await get(name, cap));
229
+ storeFile(directory, name + suffix, await get(name + suffix, CAPS.bundle));
230
+ }
231
+ return directory;
232
+ }
233
+ catch (error) {
234
+ // Retain downloaded failure evidence, without granting admission.
235
+ storeFile(directory, 'DOWNLOAD_REFUSED.json', Buffer.from(JSON.stringify({ state: 'REFUSED', authority: 'NONE', releaseEligibility: 'NOT_CHECKED' })));
236
+ throw error;
237
+ }
238
+ }
239
+ /** @param {{currentVersion:string,binaryPath:string,releaseVersion?:string,stateRoot?:string}} options */
240
+ export async function runVerifiedSelfUpgrade({ currentVersion, binaryPath, releaseVersion, stateRoot = stateHome() }) {
241
+ try {
242
+ resolveNpmInstallation(binaryPath); // Unsupported managers never trigger a download/install.
243
+ const version = releaseVersion ? stableVersion(releaseVersion) : await discoverReleaseVersion();
244
+ if (compare(version, currentVersion) < 0)
245
+ throw Error('release downgrade refused');
246
+ if (version === currentVersion)
247
+ return { state: 'ALREADY_CURRENT', authority: 'NONE', releaseEligibility: 'NOT_CHECKED', message: 'Selected version already matches; no update or artifact admission was attempted.' };
248
+ const config = readTrustedToolchain({ stateRoot });
249
+ const profile = config.schema === 2 ? config.profile : 'github-oidc';
250
+ const directory = await stageRelease(version, { stateRoot, profile });
251
+ const trust = profile === 'local-ed25519'
252
+ ? { profile, localPublicKeyPath: config.publicKey.path, localKeyFingerprint: config.publicKey.fingerprint }
253
+ : { profile, cosignPath: config.cosign.path, cosignSha256: config.cosign.sha256,
254
+ trustedRootPath: config.trustedRoot.path, trustedRootSha256: config.trustedRoot.sha256 };
255
+ const admission = admitRelease({ directory, version, currentVersion, ...trust });
256
+ return activateNpmRelease(admission, { binaryPath, stateRoot: path.join(stateRoot, 'transactions'), npmPath: config.npm.path, npmSha256: config.npm.sha256 });
257
+ }
258
+ catch (error) {
259
+ return { state: 'REFUSED', authority: 'NONE', releaseEligibility: 'NOT_CHECKED', message: error instanceof Error ? error.message : 'verified update refused' };
260
+ }
261
+ }
262
+ /** Deliberately narrow CLI: configure trust or inspect it; no candidate controls. */
263
+ export function runDistributionCommand(argv, { stateRoot = stateHome() } = {}) {
264
+ try {
265
+ if (argv[0] === 'status') {
266
+ readTrustedToolchain({ stateRoot });
267
+ return { state: 'TOOLCHAIN_CONFIGURED', releaseEligibility: 'NOT_CHECKED' };
268
+ }
269
+ if (argv[0] !== 'configure')
270
+ throw Error('usage: distribution status | configure --profile local-ed25519 --public-key PATH --key-fingerprint HASH --npm PATH --npm-sha256 HASH --acknowledge-independent-key | configure --cosign PATH --cosign-sha256 HASH --trusted-root PATH --root-sha256 HASH --npm PATH --npm-sha256 HASH --acknowledge-independent-toolchain');
271
+ const flags = new Map();
272
+ for (let i = 1; i < argv.length; i++) {
273
+ const name = argv[i];
274
+ if (flags.has(name) || !['--profile', '--public-key', '--key-fingerprint', '--acknowledge-independent-key', '--cosign', '--cosign-sha256', '--trusted-root', '--root-sha256', '--npm', '--npm-sha256', '--acknowledge-independent-toolchain'].includes(name))
275
+ throw Error('unknown or duplicate distribution option');
276
+ if (name === '--acknowledge-independent-toolchain' || name === '--acknowledge-independent-key')
277
+ flags.set(name, true);
278
+ else {
279
+ if (!argv[i + 1] || argv[i + 1].startsWith('--'))
280
+ throw Error('missing distribution option value');
281
+ flags.set(name, argv[++i]);
282
+ }
283
+ }
284
+ if (flags.get('--profile') === 'local-ed25519') {
285
+ if ([...flags.keys()].some(name => ['--cosign', '--cosign-sha256', '--trusted-root', '--root-sha256', '--acknowledge-independent-toolchain'].includes(name)))
286
+ throw Error('mixed release trust profiles refused');
287
+ return configureTrustedToolchain({ schema: 2, profile: 'local-ed25519',
288
+ publicKey: { path: flags.get('--public-key'), fingerprint: flags.get('--key-fingerprint') },
289
+ npm: { path: flags.get('--npm'), sha256: flags.get('--npm-sha256') },
290
+ }, { stateRoot, acknowledged: flags.get('--acknowledge-independent-key') === true });
291
+ }
292
+ if ([...flags.keys()].some(name => ['--profile', '--public-key', '--key-fingerprint', '--acknowledge-independent-key'].includes(name)))
293
+ throw Error('unsupported or mixed release trust profile');
294
+ return configureTrustedToolchain({ schema: 1,
295
+ cosign: { path: flags.get('--cosign'), sha256: flags.get('--cosign-sha256') },
296
+ trustedRoot: { path: flags.get('--trusted-root'), sha256: flags.get('--root-sha256') },
297
+ npm: { path: flags.get('--npm'), sha256: flags.get('--npm-sha256') },
298
+ }, { stateRoot, acknowledged: flags.get('--acknowledge-independent-toolchain') === true });
299
+ }
300
+ catch (error) {
301
+ return { state: 'REFUSED', authority: 'NONE', releaseEligibility: 'NOT_CHECKED', message: error instanceof Error ? error.message : 'toolchain configuration refused' };
302
+ }
303
+ }
@@ -93,9 +93,9 @@ export function resolveRunningBinaryPath(argv1 = process.argv[1] ?? "") {
93
93
  }
94
94
  }
95
95
  /**
96
- * Perform the actual self-upgrade: run the install command synchronously
97
- * with inherited stdio, then verify by spawning the SAME binary path with
98
- * --version.
96
+ * Retained synchronous API: classify ephemeral/no-op installs and refuse
97
+ * unverified activation. The CLI uses release-update.mjs for admission and
98
+ * transactional local-archive activation. No injected runner grants authority.
99
99
  *
100
100
  * Never throws — all failure modes are captured in the returned result so
101
101
  * callers can decide the process exit code themselves.
@@ -111,45 +111,16 @@ export function performSelfUpgrade(opts) {
111
111
  prefix: null,
112
112
  oldVersion: opts.currentVersion,
113
113
  newVersion: opts.currentVersion,
114
- message: "running via npx — npx always fetches the latest release on every run, nothing to upgrade.",
114
+ message: "running via npx — no persistent installation target to upgrade; the next invocation has its own bootstrap trust boundary.",
115
115
  };
116
116
  }
117
- const [cmd, ...cmdArgs] = plan.command;
118
- const installRun = spawnFn(cmd, cmdArgs, { stdio: "inherit" });
119
- if (installRun.error) {
120
- return {
121
- exitCode: 1,
122
- manager: plan.manager,
123
- prefix: plan.prefix,
124
- oldVersion: opts.currentVersion,
125
- newVersion: null,
126
- message: `self-upgrade failed: could not run '${plan.command.join(" ")}' — ${installRun.error.message}`,
127
- };
128
- }
129
- const installExitCode = installRun.status ?? 1;
130
- if (installExitCode !== 0) {
131
- return {
132
- exitCode: installExitCode,
133
- manager: plan.manager,
134
- prefix: plan.prefix,
135
- oldVersion: opts.currentVersion,
136
- newVersion: null,
137
- message: `self-upgrade failed: '${plan.command.join(" ")}' exited with code ${installExitCode}`,
138
- };
139
- }
140
- // Verify: spawn the SAME binary path with --version and report old -> new.
141
- const verifyRun = spawnFn(process.execPath, [binaryPath, "--version"], { encoding: "utf8" });
142
- const newVersion = verifyRun.status === 0 ? (verifyRun.stdout ?? "").trim() || null : null;
143
- const prefixNote = plan.prefix ? ` (prefix: ${plan.prefix})` : "";
117
+ // Legacy synchronous callers have no artifact-admission capability. The CLI
118
+ // uses the asynchronous verified updater; an injected process runner cannot
119
+ // turn this old registry-install path into an authorized activation.
144
120
  return {
145
- exitCode: 0,
146
- manager: plan.manager,
147
- prefix: plan.prefix,
148
- oldVersion: opts.currentVersion,
149
- newVersion,
150
- message: newVersion
151
- ? `upgraded via ${plan.manager}${prefixNote}: ${opts.currentVersion} → ${newVersion}`
152
- : `install via ${plan.manager}${prefixNote} succeeded, but could not verify the new version (--version check failed)`,
121
+ exitCode: 1, manager: plan.manager, prefix: plan.prefix,
122
+ oldVersion: opts.currentVersion, newVersion: null,
123
+ message: "self-upgrade refused: verified artifact admission is required; use the verified CLI updater",
153
124
  };
154
125
  }
155
126
  // Re-export for callers that only need the prefix-derivation logic directly.
@@ -44,6 +44,7 @@ export const PACKAGE_NAME = "great-cto";
44
44
  export const PROMPT_TIMEOUT_MS = 15_000;
45
45
  /** Commands where stdout/stderr are machine-parsed — never print a hint, never spawn a check. */
46
46
  export const PROTOCOL_SENSITIVE_COMMANDS = new Set([
47
+ "distribution", // trust/admission status is machine-readable and must not prompt
47
48
  "mcp", // stdio JSON-RPC protocol — any stray byte on stdout/stderr breaks the client
48
49
  "worker", // long-running daemon; status is polled programmatically
49
50
  "task", // machine-invoked task runner (spawned by worker)
package/dist/upgrade.js CHANGED
@@ -1,124 +1,9 @@
1
- /**
2
- * upgrade.ts — force re-clone companion plugins to their latest semver tag,
3
- * then re-apply great-cto overlays.
4
- *
5
- * `great-cto upgrade [plugin]`
6
- * plugin: "superpowers" | "beads" | undefined → all
7
- */
8
- import { existsSync, readdirSync, rmSync } from "node:fs";
9
- import { homedir } from "node:os";
10
- import { join } from "node:path";
11
- import { COMPANION_PLUGINS, installCompanionPlugin, detectLatestTag } from "./companion.js";
12
- import { applyOverlays } from "./overlay.js";
13
- import { semverDescending } from "./semver.js";
14
- function getPluginCacheDir(name) {
15
- return join(homedir(), ".claude", "plugins", "cache", "local", name);
16
- }
17
- /** Returns the highest installed semver version for a plugin, or null. */
18
- function getInstalledVersion(name) {
19
- const base = getPluginCacheDir(name);
20
- if (!existsSync(base))
21
- return null;
22
- try {
23
- const versions = readdirSync(base)
24
- .filter((v) => v.trim() !== "")
25
- .sort(semverDescending);
26
- return versions.length > 0 ? versions[0] : null;
27
- }
28
- catch {
29
- return null;
30
- }
31
- }
32
- /**
33
- * Force-upgrade a single companion plugin to its latest semver tag.
34
- * If already on the latest tag, re-applies overlays and returns `already_latest`.
35
- */
1
+ /** Legacy companion upgrades refuse before any unadmitted acquisition or mutation. */
2
+ import { COMPANION_PLUGINS, COMPANION_ADMISSION_REQUIRED } from "./companion.js";
3
+ /** Existing caches are operator state, not publisher admission. */
36
4
  export async function upgradePlugin(plugin) {
37
- const { name, repoUrl } = plugin;
38
- const currentVersion = getInstalledVersion(name);
39
- const latestTag = detectLatestTag(repoUrl);
40
- // Could not reach the remote (offline / rate-limited / git error).
41
- // If we already have a working install, keep it — never delete a good
42
- // version because of a transient network failure (upgrade is documented
43
- // as idempotent and safe to run any time).
44
- if (latestTag === null && currentVersion) {
45
- return {
46
- name,
47
- status: "skipped",
48
- fromVersion: currentVersion,
49
- toVersion: currentVersion,
50
- reason: "could not reach remote to check latest version — kept existing install",
51
- };
52
- }
53
- const latestVersion = latestTag ?? "main";
54
- // Already on latest — just re-apply overlays in case they were missing
55
- if (currentVersion && currentVersion === latestVersion) {
56
- if (name === "superpowers") {
57
- applyOverlays(join(getPluginCacheDir(name), currentVersion));
58
- }
59
- return { name, status: "already_latest", fromVersion: currentVersion, toVersion: latestVersion };
60
- }
61
- // Remove ALL existing version directories so installCompanionPlugin's
62
- // isAlreadyInstalled guard always returns null and the clone proceeds fresh.
63
- const cacheDir = getPluginCacheDir(name);
64
- if (existsSync(cacheDir)) {
65
- try {
66
- const existingVersions = readdirSync(cacheDir).filter((v) => v.trim() !== "");
67
- for (const v of existingVersions) {
68
- rmSync(join(cacheDir, v), { recursive: true, force: true });
69
- }
70
- }
71
- catch (e) {
72
- return {
73
- name,
74
- status: "skipped",
75
- fromVersion: currentVersion ?? "—",
76
- toVersion: latestVersion,
77
- reason: `failed to remove old versions: ${e.message}`,
78
- };
79
- }
80
- }
81
- // Re-install via companion installer (handles clone + settings enable).
82
- // All version dirs were removed above, so isAlreadyInstalled returns null
83
- // and the clone always proceeds fresh.
84
- const result = installCompanionPlugin(plugin);
85
- if (result.status === "skipped") {
86
- return {
87
- name,
88
- status: "skipped",
89
- fromVersion: currentVersion ?? "—",
90
- toVersion: latestVersion,
91
- reason: result.reason,
92
- };
93
- }
94
- // "installed" | "already_present" → plugin is present; apply overlays
95
- if (name === "superpowers") {
96
- const newDir = join(getPluginCacheDir(name), result.version);
97
- applyOverlays(newDir);
98
- }
99
- return {
100
- name,
101
- status: "upgraded",
102
- fromVersion: currentVersion ?? "—",
103
- toVersion: result.version,
104
- };
5
+ return { name: plugin.name, status: "skipped", fromVersion: "—", toVersion: "—", reason: COMPANION_ADMISSION_REQUIRED };
105
6
  }
106
- /** Upgrade all companion plugins. Never throws — best-effort for each. */
107
7
  export async function upgradeAll() {
108
- const results = [];
109
- for (const plugin of COMPANION_PLUGINS) {
110
- try {
111
- results.push(await upgradePlugin(plugin));
112
- }
113
- catch (e) {
114
- results.push({
115
- name: plugin.name,
116
- status: "skipped",
117
- fromVersion: "—",
118
- toVersion: "—",
119
- reason: e.message,
120
- });
121
- }
122
- }
123
- return results;
8
+ return Promise.all(COMPANION_PLUGINS.map(upgradePlugin));
124
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.70.0",
3
+ "version": "3.71.1",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,3 +0,0 @@
1
- // Fixed test-only parent, killed after the child-start handshake.
2
- import { getTasksAsync } from '../lib/beads.mjs';
3
- await getTasksAsync(process.cwd());
@@ -1,55 +0,0 @@
1
- #!/usr/bin/env node
2
- // Fixed local test protocol. No shell commands or operator stores.
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { spawn } from 'node:child_process';
6
- const root = process.env.FAKE_BD_CONTROL_ROOT;
7
- if (!root || !path.isAbsolute(root)) process.exit(2);
8
- const key = path.basename(process.cwd());
9
- const marker = name => path.join(root, `${key}.${name}`);
10
- if (process.argv.includes('--descendant')) {
11
- process.on('SIGTERM', () => {});
12
- fs.writeFileSync(marker('descendant'), String(process.pid));
13
- setInterval(() => {}, 1000);
14
- } else {
15
- fs.appendFileSync(marker('started'), `${process.pid}\n`);
16
- fs.writeFileSync(marker('worker'), String(process.ppid));
17
- if (process.env.FAKE_BD_CONTROL_MODE === 'fail') {
18
- process.exit(1);
19
- } else if (['gate-receipt', 'gate-receipt-race'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
20
- const tasks = [{ id: 'FIXTURE-GATE', title: 'gate:ship', labels: ['gate'], status: 'open', priority: 0 }];
21
- if (process.env.FAKE_BD_CONTROL_MODE === 'gate-receipt-race') tasks.push({ id: 'NEW-GATE', title: 'gate:plan', labels: ['gate'], status: 'open', priority: 0 });
22
- process.stdout.write(JSON.stringify(tasks), () => process.exit(0));
23
- } else if (['hang', 'leader-exit'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
24
- process.on('SIGTERM', () => {});
25
- spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'inherit' });
26
- if (process.env.FAKE_BD_CONTROL_MODE === 'leader-exit') {
27
- setInterval(() => { if (fs.existsSync(marker('descendant'))) process.exit(0); }, 25);
28
- }
29
- setInterval(() => {}, 1000);
30
- } else if (process.env.FAKE_BD_CONTROL_MODE === 'large-success') {
31
- const payload = JSON.stringify([{ id: 'FIXTURE-1', title: 'x'.repeat(2 * 1024 * 1024), status: 'open', priority: 0 }]);
32
- const accepted = process.stdout.write(payload, () => process.exit(0));
33
- // Signal queued output, not drain: drain can require the paused consumer
34
- // to resume. A completion-callback marker creates a circular wait.
35
- fs.writeFileSync(marker('output-queued'), JSON.stringify({ needsDrain: !accepted, bytes: Buffer.byteLength(payload) }));
36
- } else if (['closed-success', 'closed-fail'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
37
- const descendant = spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'ignore' });
38
- descendant.unref();
39
- const ready = setInterval(() => {
40
- if (!fs.existsSync(marker('descendant'))) return;
41
- clearInterval(ready);
42
- process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]),
43
- () => process.exit(process.env.FAKE_BD_CONTROL_MODE === 'closed-fail' ? 1 : 0));
44
- }, 25);
45
- } else if (process.env.FAKE_BD_CONTROL_MODE === 'oversize') {
46
- process.stdout.write(Buffer.alloc(16 * 1024 * 1024 + 1, 120));
47
- setInterval(() => {}, 1000);
48
- } else {
49
- const timer = setInterval(() => {
50
- if (!fs.existsSync(marker('release'))) return;
51
- clearInterval(timer);
52
- process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]));
53
- }, 25);
54
- }
55
- }
@@ -1,9 +0,0 @@
1
- #!/bin/bash
2
- # Test fixture: a `bd` stand-in that always exits non-zero, for testing the
3
- # failure path of warmTasksAsync (packages/board/beads-warm-async.test.mjs).
4
- if [ "$1" = "--version" ]; then
5
- echo "bd-fake 0.0.0"
6
- exit 0
7
- fi
8
- echo "dolt: database is locked" >&2
9
- exit 1
@@ -1,12 +0,0 @@
1
- #!/bin/bash
2
- # Test fixture: a `bd` stand-in that sleeps before answering, so the
3
- # boot-responsiveness tests can prove the board stays answerable DURING a
4
- # slow `bd list` — not just that the code calls the right function. Loaded
5
- # only via GREAT_CTO_BD_BIN in packages/board/*.test.mjs; never used outside
6
- # tests.
7
- if [ "$1" = "--version" ]; then
8
- echo "bd-fake 0.0.0"
9
- exit 0
10
- fi
11
- sleep "${FAKE_BD_DELAY_SECS:-3}"
12
- echo "[]"
@@ -1,21 +0,0 @@
1
- #!/usr/bin/env node
2
- // A fixed Git-read fixture. It never reads operator stores or invokes a shell.
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { spawnSync } from 'node:child_process';
6
- const root = process.env.GCTO_RECEIPT_CONTROL_ROOT, binary = process.env.GCTO_RECEIPT_REAL_GIT;
7
- if (!root || !path.isAbsolute(root) || !binary || !path.isAbsolute(binary)) process.exit(2);
8
- const args = process.argv.slice(2);
9
- const run = () => {
10
- const result = spawnSync(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
11
- if (result.stdout) process.stdout.write(result.stdout);
12
- if (result.stderr) process.stderr.write(result.stderr);
13
- process.exit(result.status ?? 1);
14
- };
15
- if (args.includes('hash-object') || args.includes('--show-object-format') ||
16
- (args.includes('rev-parse') && args.includes('HEAD') && fs.existsSync(path.join(root, 'hold-head')))) {
17
- fs.appendFileSync(path.join(root, 'git-held'), `${process.pid}\n`);
18
- const timer = setInterval(() => {
19
- if (fs.existsSync(path.join(root, 'git-release'))) { clearInterval(timer); run(); }
20
- }, 25);
21
- } else run();