ghostrail 0.20.1 → 0.22.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/dist/board/page.d.ts.map +1 -1
- package/dist/board/page.js +76 -14
- package/dist/board/page.js.map +1 -1
- package/dist/board/server.d.ts +10 -0
- package/dist/board/server.d.ts.map +1 -1
- package/dist/board/server.js +24 -0
- package/dist/board/server.js.map +1 -1
- package/dist/cli/commands.d.ts +56 -1
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +203 -20
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/help.d.ts.map +1 -1
- package/dist/commands/help.js +10 -0
- package/dist/commands/help.js.map +1 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +19 -0
- package/dist/config/load.js.map +1 -1
- package/dist/config/schema.d.ts +13 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/dashboard/page.d.ts.map +1 -1
- package/dist/dashboard/page.js +226 -9
- package/dist/dashboard/page.js.map +1 -1
- package/dist/dashboard/review-page.d.ts +5 -0
- package/dist/dashboard/review-page.d.ts.map +1 -1
- package/dist/dashboard/review-page.js +23 -3
- package/dist/dashboard/review-page.js.map +1 -1
- package/dist/dashboard/routes.d.ts +17 -0
- package/dist/dashboard/routes.d.ts.map +1 -1
- package/dist/dashboard/routes.js +25 -0
- package/dist/dashboard/routes.js.map +1 -1
- package/dist/dashboard/server.d.ts +29 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +91 -2
- package/dist/dashboard/server.js.map +1 -1
- package/dist/doctor/checks.d.ts +1 -0
- package/dist/doctor/checks.d.ts.map +1 -1
- package/dist/doctor/checks.js +1 -1
- package/dist/doctor/checks.js.map +1 -1
- package/dist/doctor/probes.d.ts +19 -3
- package/dist/doctor/probes.d.ts.map +1 -1
- package/dist/doctor/probes.js +22 -27
- package/dist/doctor/probes.js.map +1 -1
- package/dist/doctor/run.d.ts.map +1 -1
- package/dist/doctor/run.js +17 -3
- package/dist/doctor/run.js.map +1 -1
- package/dist/factory/build.d.ts +6 -0
- package/dist/factory/build.d.ts.map +1 -1
- package/dist/factory/build.js +26 -0
- package/dist/factory/build.js.map +1 -1
- package/dist/loop/next-tick.d.ts +50 -0
- package/dist/loop/next-tick.d.ts.map +1 -0
- package/dist/loop/next-tick.js +87 -0
- package/dist/loop/next-tick.js.map +1 -0
- package/dist/loop/schedule.d.ts +11 -2
- package/dist/loop/schedule.d.ts.map +1 -1
- package/dist/loop/schedule.js +3 -1
- package/dist/loop/schedule.js.map +1 -1
- package/dist/loop/tick-log.d.ts +13 -0
- package/dist/loop/tick-log.d.ts.map +1 -0
- package/dist/loop/tick-log.js +64 -0
- package/dist/loop/tick-log.js.map +1 -0
- package/dist/publish/gh.d.ts +43 -2
- package/dist/publish/gh.d.ts.map +1 -1
- package/dist/publish/gh.js +80 -2
- package/dist/publish/gh.js.map +1 -1
- package/dist/publish/githost.d.ts +18 -1
- package/dist/publish/githost.d.ts.map +1 -1
- package/dist/publish/githost.js +13 -0
- package/dist/publish/githost.js.map +1 -1
- package/dist/publish/index.d.ts +1 -1
- package/dist/publish/index.d.ts.map +1 -1
- package/dist/publish/index.js +1 -1
- package/dist/publish/index.js.map +1 -1
- package/dist/release/index.d.ts +2 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +2 -0
- package/dist/release/index.js.map +1 -0
- package/dist/release/release.d.ts +67 -0
- package/dist/release/release.d.ts.map +1 -0
- package/dist/release/release.js +193 -0
- package/dist/release/release.js.map +1 -0
- package/dist/respond/ci-fix-queue.d.ts +40 -0
- package/dist/respond/ci-fix-queue.d.ts.map +1 -0
- package/dist/respond/ci-fix-queue.js +74 -0
- package/dist/respond/ci-fix-queue.js.map +1 -0
- package/dist/respond/index.d.ts +2 -1
- package/dist/respond/index.d.ts.map +1 -1
- package/dist/respond/index.js +2 -1
- package/dist/respond/index.js.map +1 -1
- package/dist/respond/merge.d.ts.map +1 -1
- package/dist/respond/merge.js +17 -1
- package/dist/respond/merge.js.map +1 -1
- package/dist/respond/respond.d.ts +33 -1
- package/dist/respond/respond.d.ts.map +1 -1
- package/dist/respond/respond.js +155 -9
- package/dist/respond/respond.js.map +1 -1
- package/dist/store/events.d.ts +30 -0
- package/dist/store/events.d.ts.map +1 -1
- package/dist/store/events.js +38 -0
- package/dist/store/events.js.map +1 -1
- package/dist/store/index.d.ts +1 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +1 -1
- package/dist/store/index.js.map +1 -1
- package/dist/triage/engine.d.ts +6 -2
- package/dist/triage/engine.d.ts.map +1 -1
- package/dist/triage/engine.js +6 -1
- package/dist/triage/engine.js.map +1 -1
- package/package.json +1 -1
- package/skill/ghostrail/SKILL.md +15 -4
- package/templates/code/ghostrail.toml +3 -0
- package/templates/code/prompts/release.md +40 -0
- package/templates/code/prompts/respond.md +2 -1
- package/templates/content/prompts/respond.md +3 -1
package/dist/cli/commands.js
CHANGED
|
@@ -12,7 +12,7 @@ import { parseDuration } from "../config/duration.js";
|
|
|
12
12
|
import { parseConfig, parseMergedConfig } from "../config/load.js";
|
|
13
13
|
import { MERGE_TRIGGER_COMMENT, createDashboardServer, defaultStateDir, } from "../dashboard/index.js";
|
|
14
14
|
import { hasFailure, renderDoctorJson, renderDoctorText, runDoctor, } from "../doctor/index.js";
|
|
15
|
-
import { buildLoopDeps, buildRespondDeps, buildTriageDeps, repoGhRunner, resolveRepoSlug, } from "../factory/build.js";
|
|
15
|
+
import { buildLoopDeps, buildReleaseDeps, buildRespondDeps, buildTriageDeps, repoGhRunner, resolveRepoSlug, } from "../factory/build.js";
|
|
16
16
|
import { describeMigration, migrateLegacyStore, resolveWorkRepo, stateDirFor, } from "../factory/state.js";
|
|
17
17
|
import { entryKey, findByPath, findForRepoResolving, replaceEntry, upsert, } from "../global/registry.js";
|
|
18
18
|
import { canonicalRepoId, localRepoId, repoKey } from "../global/repo-id.js";
|
|
@@ -28,10 +28,14 @@ import { concurrencyAdvice } from "../loop/advice.js";
|
|
|
28
28
|
import { fileControl } from "../loop/control.js";
|
|
29
29
|
import { readControl } from "../loop/control.js";
|
|
30
30
|
import { runTick } from "../loop/engine.js";
|
|
31
|
+
import { clearTickStarted, markTickStarted, writeNextTickAt } from "../loop/next-tick.js";
|
|
31
32
|
import { duePasses, } from "../loop/schedule.js";
|
|
33
|
+
import { appendLog } from "../loop/tick-log.js";
|
|
32
34
|
import { watchLoop } from "../loop/watch.js";
|
|
33
35
|
import { GhHost, parseRepoSlug } from "../publish/gh.js";
|
|
36
|
+
import { releaseNow, releaseTick, requestRelease } from "../release/release.js";
|
|
34
37
|
import { resolveIgnoredAuthors } from "../respond/authors.js";
|
|
38
|
+
import { FileCiFixQueueStore } from "../respond/ci-fix-queue.js";
|
|
35
39
|
import { respondTick } from "../respond/respond.js";
|
|
36
40
|
import { GLOBAL_SECRETS_FILE, appendPlaceholders, applyOverrides, launchEditor as defaultLaunchEditor, loadEnvOverrides, missingSecrets, readSecretsFile, redactSecrets, renderFile, resolveEditor, secretValuesOf, writeSecretsFile, } from "../secrets/index.js";
|
|
37
41
|
import { DASHBOARD_UNIT, renderDashboardUnit, servicePath, stopTimeoutSeconds, systemdManager, unitName, } from "../service/index.js";
|
|
@@ -68,6 +72,27 @@ function makeLogger(streams, secrets) {
|
|
|
68
72
|
warn: (message) => streams.err(`${redactSecrets(message, secrets)}\n`),
|
|
69
73
|
};
|
|
70
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* `watch`'s logger: everything `makeLogger` does, plus a redacted copy of
|
|
77
|
+
* every line appended to this repo's rolling watch log (`src/loop/tick-log.ts`),
|
|
78
|
+
* so the dashboard has something to show for a daemon it has no live
|
|
79
|
+
* connection to. `ghostrail run` and a standalone respond/triage pass keep
|
|
80
|
+
* using the plain logger: only the watch loop is what the dashboard's log
|
|
81
|
+
* section is about.
|
|
82
|
+
*/
|
|
83
|
+
function watchLogger(streams, secrets, dir) {
|
|
84
|
+
const base = makeLogger(streams, secrets);
|
|
85
|
+
return {
|
|
86
|
+
info: (message) => {
|
|
87
|
+
base.info(message);
|
|
88
|
+
void appendLog(dir, redactSecrets(message, secrets));
|
|
89
|
+
},
|
|
90
|
+
warn: (message) => {
|
|
91
|
+
base.warn(message);
|
|
92
|
+
void appendLog(dir, `WARN ${redactSecrets(message, secrets)}`);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
71
96
|
/**
|
|
72
97
|
* The effective `ghostrail.toml` path for a repo: the repo's own file if it
|
|
73
98
|
* has one, else wherever its `.ghostrail-config` pointer file names, else
|
|
@@ -164,8 +189,13 @@ async function resolveClaudeProfile(env) {
|
|
|
164
189
|
* Shared setup for the run/watch loops: apply secrets, load the merged config,
|
|
165
190
|
* require the Linear key, and assemble the loop deps. Prints errors and returns
|
|
166
191
|
* undefined on failure.
|
|
192
|
+
*
|
|
193
|
+
* `loggerFor` is the seam TJ-1682 turned on: the logger the deps are built with
|
|
194
|
+
* is decided here, because only here is the state directory known, and a caller
|
|
195
|
+
* that builds its own afterwards cannot reach the engine's narration. Exported
|
|
196
|
+
* so that invariant can be tested directly rather than inferred from a tick.
|
|
167
197
|
*/
|
|
168
|
-
async function prepareLoop(flags, streams, env) {
|
|
198
|
+
export async function prepareLoop(flags, streams, env, loggerFor = (_dir, secrets) => makeLogger(streams, secrets)) {
|
|
169
199
|
const secrets = await applySecrets(flags, env);
|
|
170
200
|
const loaded = await loadConfig(flags, streams, env);
|
|
171
201
|
if (loaded === undefined)
|
|
@@ -189,6 +219,9 @@ async function prepareLoop(flags, streams, env) {
|
|
|
189
219
|
// of one repo running overlapping ticks: while the store lived inside each
|
|
190
220
|
// checkout they took different locks and nothing serialized them.
|
|
191
221
|
const lock = new FileLock(join(state.dir, "lock"), config.guardrails.claimTtlMs);
|
|
222
|
+
// Keyed on the state dir because that is what a persisting logger needs and
|
|
223
|
+
// only `prepareLoop` knows it: the caller cannot build this itself (TJ-1682).
|
|
224
|
+
const logger = loggerFor(state.dir, secrets);
|
|
192
225
|
const ctx = {
|
|
193
226
|
repoPath: flags.repo,
|
|
194
227
|
...(work.path === undefined ? {} : { workRepoPath: work.path }),
|
|
@@ -204,10 +237,10 @@ async function prepareLoop(flags, streams, env) {
|
|
|
204
237
|
...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
|
|
205
238
|
...(promptSourceDir === undefined ? {} : { promptSourceDir }),
|
|
206
239
|
...(repoSlug === undefined ? {} : { repoSlug }),
|
|
207
|
-
logger
|
|
240
|
+
logger,
|
|
208
241
|
};
|
|
209
242
|
const deps = buildLoopDeps(config, ctx);
|
|
210
|
-
return { config, deps, secrets, stateDir: state.dir, ctx };
|
|
243
|
+
return { config, deps, secrets, stateDir: state.dir, ctx, logger };
|
|
211
244
|
}
|
|
212
245
|
/** `ghostrail run`: drain eligible work once. */
|
|
213
246
|
export async function cmdRun(argv, streams, env = process.env, cwd = process.cwd()) {
|
|
@@ -270,6 +303,10 @@ function schedulablePasses(setup, streams) {
|
|
|
270
303
|
streams.err(`not watching triage: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
271
304
|
}
|
|
272
305
|
}
|
|
306
|
+
if (setup.config.release !== undefined) {
|
|
307
|
+
const { deps, config: releaseConfig } = buildReleaseDeps(setup.config, setup.ctx);
|
|
308
|
+
passes.release = () => releaseTick(deps, releaseConfig);
|
|
309
|
+
}
|
|
273
310
|
return passes;
|
|
274
311
|
}
|
|
275
312
|
/** The intervals for the passes that are actually wired up. */
|
|
@@ -281,11 +318,14 @@ function intervalsOf(config, passes) {
|
|
|
281
318
|
if (passes.triage !== undefined && config.triage?.everyMs !== undefined) {
|
|
282
319
|
intervals.triage = config.triage.everyMs;
|
|
283
320
|
}
|
|
321
|
+
if (passes.release !== undefined) {
|
|
322
|
+
intervals.release = true;
|
|
323
|
+
}
|
|
284
324
|
return intervals;
|
|
285
325
|
}
|
|
286
|
-
/** The ", plus respond
|
|
326
|
+
/** The ", plus respond and triage" tail of the watch banner. */
|
|
287
327
|
function describeSchedule(passes) {
|
|
288
|
-
const extra = ["respond", "triage"].filter((pass) => passes[pass] !== undefined);
|
|
328
|
+
const extra = ["respond", "triage", "release"].filter((pass) => passes[pass] !== undefined);
|
|
289
329
|
return extra.length === 0 ? "" : `, plus ${extra.join(" and ")}`;
|
|
290
330
|
}
|
|
291
331
|
export async function cmdWatch(argv, streams, env = process.env, cwd = process.cwd()) {
|
|
@@ -295,14 +335,26 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
|
|
|
295
335
|
streams.err('invalid --interval (use e.g. "30s", "5m", "1h")\n');
|
|
296
336
|
return 1;
|
|
297
337
|
}
|
|
298
|
-
|
|
338
|
+
// The persisting logger has to be the one the loop deps are built with, not a
|
|
339
|
+
// second one made afterwards: the engine narrates through `ctx.logger`, and a
|
|
340
|
+
// logger `watch` keeps to itself only ever sees pause, resume, and a thrown
|
|
341
|
+
// tick, which is why the log stayed empty (TJ-1682).
|
|
342
|
+
const setup = await prepareLoop(flags, streams, env, (dir, secrets) => watchLogger(streams, secrets, dir));
|
|
299
343
|
if (setup === undefined)
|
|
300
344
|
return 1;
|
|
301
345
|
const controller = new AbortController();
|
|
302
346
|
const onSignal = () => controller.abort();
|
|
303
347
|
process.once("SIGINT", onSignal);
|
|
304
348
|
process.once("SIGTERM", onSignal);
|
|
305
|
-
const logger =
|
|
349
|
+
const logger = setup.logger;
|
|
350
|
+
// A process killed mid-tick leaves its marker behind; starting up is the one
|
|
351
|
+
// moment we can be certain no tick of ours is running (TJ-1683).
|
|
352
|
+
try {
|
|
353
|
+
await clearTickStarted(setup.stateDir);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Display state only. Never worth refusing to start over.
|
|
357
|
+
}
|
|
306
358
|
const passes = schedulablePasses(setup, streams);
|
|
307
359
|
streams.err(`watching every ${watch.intervalLabel}${describeSchedule(passes)}; press Ctrl-C to stop\n`);
|
|
308
360
|
try {
|
|
@@ -317,6 +369,15 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
|
|
|
317
369
|
isEnabled: async () => (await readControl(controlDir)).enabled,
|
|
318
370
|
...(watch.maxTicks === undefined ? {} : { maxTicks: watch.maxTicks }),
|
|
319
371
|
tick: async () => {
|
|
372
|
+
// Best-effort and for display only (specs/dashboard-log.spec.md): a
|
|
373
|
+
// tick that outlasts the interval is ordinary here, and without this
|
|
374
|
+
// the countdown reaches zero and has nothing left to say (TJ-1683).
|
|
375
|
+
try {
|
|
376
|
+
await markTickStarted(setup.stateDir, Date.now());
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// the countdown is a convenience, not something the loop depends on
|
|
380
|
+
}
|
|
320
381
|
const due = duePasses({
|
|
321
382
|
now: Date.now(),
|
|
322
383
|
intervals: intervalsOf(setup.config, passes),
|
|
@@ -328,7 +389,11 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
|
|
|
328
389
|
continue;
|
|
329
390
|
try {
|
|
330
391
|
const result = await run();
|
|
331
|
-
streams.out
|
|
392
|
+
// Through the logger, not `streams.out`: this is the most useful
|
|
393
|
+
// line a tick produces, and writing it straight to stdout kept it
|
|
394
|
+
// out of the log the dashboard shows (TJ-1682). `watchLogger`
|
|
395
|
+
// redacts, so this no longer redacts a second time.
|
|
396
|
+
logger.info(`${pass}: ${JSON.stringify(result, null, 2)}`);
|
|
332
397
|
}
|
|
333
398
|
catch (error) {
|
|
334
399
|
// One broken pass must not disable the other two, and must not
|
|
@@ -337,9 +402,19 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
|
|
|
337
402
|
// an agent-backed pass means spending money in a loop.
|
|
338
403
|
logger.warn(`${pass} pass failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
339
404
|
}
|
|
340
|
-
|
|
405
|
+
// `release` has no interval to stamp (see PassIntervals), same as `fix`.
|
|
406
|
+
if (pass !== "fix" && pass !== "release")
|
|
341
407
|
lastRunAt[pass] = Date.now();
|
|
342
408
|
}
|
|
409
|
+
// Best-effort and for display only (specs/dashboard-log.spec.md): the
|
|
410
|
+
// dashboard's countdown reads this back, but nothing here depends on
|
|
411
|
+
// it, so a write failure must not touch the loop.
|
|
412
|
+
try {
|
|
413
|
+
await writeNextTickAt(setup.stateDir, Date.now() + watch.intervalMs);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// the countdown is a convenience, not something the loop depends on
|
|
417
|
+
}
|
|
343
418
|
},
|
|
344
419
|
});
|
|
345
420
|
streams.err(`stopped after ${ticks} tick(s)\n`);
|
|
@@ -439,6 +514,43 @@ export async function cmdRespond(argv, streams, env = process.env, cwd = process
|
|
|
439
514
|
streams.out(`${redactSecrets(JSON.stringify(results, null, 2), secrets)}\n`);
|
|
440
515
|
return results.some((r) => r.state === "failed") ? 1 : 0;
|
|
441
516
|
}
|
|
517
|
+
/** `ghostrail release`: cut one release now. What "release" means is entirely [release].prompt's business. */
|
|
518
|
+
export async function cmdRelease(argv, streams, env = process.env, cwd = process.cwd()) {
|
|
519
|
+
const flags = parseCommandFlags(argv, { config: "ghostrail.toml", repo: cwd });
|
|
520
|
+
const secrets = await applySecrets(flags, env);
|
|
521
|
+
const loaded = await loadConfig(flags, streams, env);
|
|
522
|
+
if (loaded === undefined)
|
|
523
|
+
return 1;
|
|
524
|
+
const { config, promptSourceDir } = loaded;
|
|
525
|
+
if (config.release === undefined) {
|
|
526
|
+
streams.err("no [release] section in the config; add one to use `ghostrail release`\n");
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
const claudeProfileDir = await resolveClaudeProfile(env);
|
|
530
|
+
const releaseState = await prepareState(flags.repo, config, env, streams);
|
|
531
|
+
const releaseWork = await resolveWorkRepo({
|
|
532
|
+
repoPath: flags.repo,
|
|
533
|
+
stateDir: releaseState.dir,
|
|
534
|
+
config,
|
|
535
|
+
env,
|
|
536
|
+
});
|
|
537
|
+
if (releaseWork.note !== undefined)
|
|
538
|
+
streams.err(`${releaseWork.note}\n`);
|
|
539
|
+
// No LINEAR_API_KEY requirement: a release touches no tracker item.
|
|
540
|
+
const { deps, config: releaseConfig } = buildReleaseDeps(config, {
|
|
541
|
+
repoPath: flags.repo,
|
|
542
|
+
...(releaseWork.path === undefined ? {} : { workRepoPath: releaseWork.path }),
|
|
543
|
+
stateDir: releaseState.dir,
|
|
544
|
+
linearApiKey: env.LINEAR_API_KEY ?? "",
|
|
545
|
+
lock: { acquire: async () => true, release: async () => { } },
|
|
546
|
+
...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
|
|
547
|
+
...(promptSourceDir === undefined ? {} : { promptSourceDir }),
|
|
548
|
+
logger: makeLogger(streams, secrets),
|
|
549
|
+
});
|
|
550
|
+
const result = await releaseNow(deps, releaseConfig);
|
|
551
|
+
streams.out(`${redactSecrets(JSON.stringify(result, null, 2), secrets)}\n`);
|
|
552
|
+
return result.state === "failed" ? 1 : 0;
|
|
553
|
+
}
|
|
442
554
|
/** Parse `board` flags: `--repo`, `--port`, `--bind`, `--token`. */
|
|
443
555
|
export function parseBoardFlags(argv, defaults) {
|
|
444
556
|
const flags = { ...defaults };
|
|
@@ -514,6 +626,10 @@ export async function cmdBoard(argv, streams, env = process.env, cwd = process.c
|
|
|
514
626
|
bind: flags.bind,
|
|
515
627
|
gitHost,
|
|
516
628
|
control: fileControl(store.dir),
|
|
629
|
+
// Same directory `buildRespondDeps` resolves for this repo and config
|
|
630
|
+
// (`stateDirFor`), so a "Fix CI" click here is what the next
|
|
631
|
+
// `ghostrail respond` tick actually reads.
|
|
632
|
+
ciFixQueue: new FileCiFixQueueStore(join(store.dir, "ci-fix-queue.json")),
|
|
517
633
|
...(flags.token === undefined ? {} : { token: flags.token }),
|
|
518
634
|
...(unauthenticated ? { allowNoToken: true } : {}),
|
|
519
635
|
});
|
|
@@ -604,12 +720,23 @@ function dashboardDeps(env, manager) {
|
|
|
604
720
|
// subprocess. A machine with no `gh` and no credentials still serves the
|
|
605
721
|
// whole dashboard, with PR status reported as unavailable.
|
|
606
722
|
prStatus: async (entry, prNumber) => new GhHost(repoGhRunner(entry.path), await slugFor(entry)).getPrStatus(prNumber),
|
|
723
|
+
// The same directory `buildRespondDeps` resolves for this repo and
|
|
724
|
+
// config, so a "Fix CI" click here is what that ghostrail's next
|
|
725
|
+
// `ghostrail respond` tick actually reads.
|
|
726
|
+
ciFixQueue: async (entry) => new FileCiFixQueueStore(join(await defaultStateDir(entry), "ci-fix-queue.json")),
|
|
607
727
|
// Posts the same trigger comment a human typing "resolve conflicts and
|
|
608
728
|
// merge" by hand would; the next respond pass does the actual work (see
|
|
609
729
|
// specs/dashboard-review-mode.spec.md). A machine with no `gh` and no
|
|
610
730
|
// credentials still serves the whole dashboard; the route just answers
|
|
611
731
|
// 503 rather than failing to start.
|
|
612
732
|
mergeTrigger: async (entry, prNumber) => new GhHost(repoGhRunner(entry.path), await slugFor(entry)).postComment(prNumber, MERGE_TRIGGER_COMMENT),
|
|
733
|
+
releaseEnabled: (path) => releaseEnabledFor(path),
|
|
734
|
+
// Queuing a release is a local file write plus a store event, not a `gh`
|
|
735
|
+
// call, so this never needs the "no credentials" fallback mergeTrigger has.
|
|
736
|
+
releaseTrigger: async (entry) => {
|
|
737
|
+
const dir = await defaultStateDir(entry);
|
|
738
|
+
return requestRelease(dir, new FileStore(dir));
|
|
739
|
+
},
|
|
613
740
|
readRegistry: () => store.readRegistry(),
|
|
614
741
|
writeRegistry: (entries) => store.writeRegistry(entries),
|
|
615
742
|
health: ghostrailHealth,
|
|
@@ -696,6 +823,25 @@ export async function ghostrailHealth(path) {
|
|
|
696
823
|
return "missing";
|
|
697
824
|
return (await resolveConfigPath(path)) !== undefined ? "ok" : "no config";
|
|
698
825
|
}
|
|
826
|
+
/**
|
|
827
|
+
* Whether a registered ghostrail has `[release]` configured — the dashboard's
|
|
828
|
+
* gate for showing the release button. False for anything that cannot be
|
|
829
|
+
* read or parsed, the same leniency `ghostrailHealth` already has: a config
|
|
830
|
+
* problem hides the button rather than erroring the whole listing.
|
|
831
|
+
*/
|
|
832
|
+
export async function releaseEnabledFor(path) {
|
|
833
|
+
const configPath = await resolveConfigPath(path);
|
|
834
|
+
if (configPath === undefined)
|
|
835
|
+
return false;
|
|
836
|
+
try {
|
|
837
|
+
const toml = await readFile(configPath, "utf8");
|
|
838
|
+
const parsed = parseConfig(toml);
|
|
839
|
+
return parsed.ok && parsed.value.release !== undefined;
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
699
845
|
/** `ghostrail ls`: the ghostrails this account knows about. */
|
|
700
846
|
export async function cmdLs(_argv, streams, env = process.env) {
|
|
701
847
|
const store = globalStoreFor(env);
|
|
@@ -1192,6 +1338,12 @@ async function ghostrailName(repo) {
|
|
|
1192
1338
|
* as it is, because re-running `init --update` must not switch `watch` back on
|
|
1193
1339
|
* for a ghostrail the operator deliberately turned off.
|
|
1194
1340
|
*
|
|
1341
|
+
* `options.repoint` gates the one case that is an act, not an add: an entry
|
|
1342
|
+
* that already has an identity, pointing at a checkout other than this one.
|
|
1343
|
+
* Off by default, an existing entry is reported and left alone rather than
|
|
1344
|
+
* rewritten, so a second clone of a registered repo cannot silently steal the
|
|
1345
|
+
* entry that governs what `start` runs unattended (TJ-1652).
|
|
1346
|
+
*
|
|
1195
1347
|
* Never fails the command. Scaffolding the repo is the job; registering it is a
|
|
1196
1348
|
* convenience, and aborting a good scaffold over an unwritable config dir would
|
|
1197
1349
|
* be the worse trade.
|
|
@@ -1241,11 +1393,11 @@ async function prepareState(repo, config, env, streams) {
|
|
|
1241
1393
|
streams.err(`${note}\n`);
|
|
1242
1394
|
return { dir: state.dir, repoId: state.repoId };
|
|
1243
1395
|
}
|
|
1244
|
-
async function registerGhostrail(repo, env, streams,
|
|
1396
|
+
async function registerGhostrail(repo, env, streams, options = {}) {
|
|
1245
1397
|
const store = globalStoreFor(env);
|
|
1246
1398
|
const path = resolve(repo);
|
|
1247
1399
|
try {
|
|
1248
|
-
if (
|
|
1400
|
+
if (options.forceRegister !== true && (await isTempPath(path, env))) {
|
|
1249
1401
|
// `watch` defaults to true and, for a repo with no remote, `path` is the
|
|
1250
1402
|
// identity: registering here would switch on a service unit pointed at a
|
|
1251
1403
|
// directory that is going to vanish. Scaffolding still happened; only the
|
|
@@ -1261,12 +1413,27 @@ async function registerGhostrail(repo, env, streams, forceRegister = false) {
|
|
|
1261
1413
|
const repoId = await resolveRepoId(path);
|
|
1262
1414
|
const existing = await findForRepoResolving(entries, repoId, path, resolveRepoId);
|
|
1263
1415
|
if (existing !== undefined) {
|
|
1416
|
+
if (existing.repo === repoId && existing.path === path)
|
|
1417
|
+
return;
|
|
1418
|
+
// An entry that already has an identity, at a *different* path than this
|
|
1419
|
+
// checkout, is a deliberate repoint, not a migration. `--update` promises
|
|
1420
|
+
// to write only what it can prove is safe everywhere else, and moving the
|
|
1421
|
+
// entry that governs what `start` runs unattended is a far bigger act
|
|
1422
|
+
// than anything else it does, so it never happens as a side effect of
|
|
1423
|
+
// asking for template changes. Report it and leave the registry alone,
|
|
1424
|
+
// the way a stale entry is already reported rather than pruned.
|
|
1425
|
+
if (existing.repo !== undefined && options.repoint !== true) {
|
|
1426
|
+
streams.out(`this repo already has the ghostrail "${existing.name}", registered at a different checkout
|
|
1427
|
+
registered: ${existing.path}
|
|
1428
|
+
this one: ${path}
|
|
1429
|
+
\`ghostrail ls\` lists it; re-run with --repoint to point it here instead
|
|
1430
|
+
`);
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1264
1433
|
// Everything the entry already carries survives, `watch` above all:
|
|
1265
1434
|
// re-registering must never switch a ghostrail back on that the operator
|
|
1266
1435
|
// deliberately turned off.
|
|
1267
1436
|
const adopted = { ...existing, repo: repoId, path };
|
|
1268
|
-
if (existing.repo === repoId && existing.path === path)
|
|
1269
|
-
return;
|
|
1270
1437
|
// By the entry's *old* key: adopting an identity re-keys it, so an
|
|
1271
1438
|
// ordinary upsert would append beside it rather than replace it.
|
|
1272
1439
|
await store.writeRegistry(replaceEntry(entries, entryKey(existing), adopted));
|
|
@@ -1333,7 +1500,7 @@ async function promptPathsAt(configPath) {
|
|
|
1333
1500
|
* the template gained, which is what every hand port consisted of before
|
|
1334
1501
|
* regions existed, and the skill places them in the repo's own wording.
|
|
1335
1502
|
*/
|
|
1336
|
-
async function applyTemplateUpdate(template, srcDir, repo, streams, env,
|
|
1503
|
+
async function applyTemplateUpdate(template, srcDir, repo, streams, env, options) {
|
|
1337
1504
|
const manifest = await readManifest(repo);
|
|
1338
1505
|
const rels = await scaffoldFiles(srcDir);
|
|
1339
1506
|
const templatePrompts = (await promptPathsAt(join(srcDir, "ghostrail.toml"))) ?? {};
|
|
@@ -1458,7 +1625,10 @@ async function applyTemplateUpdate(template, srcDir, repo, streams, env, registe
|
|
|
1458
1625
|
if (Object.keys(written).length > 0 || Object.keys(writtenRegions).length > 0) {
|
|
1459
1626
|
await writeManifest(repo, mergeManifest(manifest, template, VERSION, written, writtenRegions));
|
|
1460
1627
|
}
|
|
1461
|
-
await registerGhostrail(repo, env, streams,
|
|
1628
|
+
await registerGhostrail(repo, env, streams, {
|
|
1629
|
+
repoint: options.repoint,
|
|
1630
|
+
forceRegister: options.register,
|
|
1631
|
+
});
|
|
1462
1632
|
streams.out("\n");
|
|
1463
1633
|
if (took > 0)
|
|
1464
1634
|
streams.out(`took ${took} file(s) you had not edited\n`);
|
|
@@ -1502,7 +1672,8 @@ async function removeLocalScaffold(repo, rels) {
|
|
|
1502
1672
|
}
|
|
1503
1673
|
}
|
|
1504
1674
|
/**
|
|
1505
|
-
* `ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom
|
|
1675
|
+
* `ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom
|
|
1676
|
+
* [<path>]] [--register] [--repoint]`:
|
|
1506
1677
|
* scaffold `ghostrail.toml` and `prompts/` from a quickstart template. Safe for
|
|
1507
1678
|
* a fresh OR an existing repo: existing files are left untouched (skipped)
|
|
1508
1679
|
* unless `--force` is given. `--diff` reports drift instead of writing
|
|
@@ -1514,6 +1685,11 @@ async function removeLocalScaffold(repo, rels) {
|
|
|
1514
1685
|
*
|
|
1515
1686
|
* Registration is skipped, not just the write, when the target looks like a
|
|
1516
1687
|
* temp directory (TJ-1654); `--register` registers it anyway.
|
|
1688
|
+
*
|
|
1689
|
+
* `--repoint` opts into the one registry write that is an act rather than an
|
|
1690
|
+
* add: moving an already-registered ghostrail's entry to this checkout when it
|
|
1691
|
+
* is currently pointing somewhere else. Without it, that mismatch is reported
|
|
1692
|
+
* and the registry is left alone (TJ-1652).
|
|
1517
1693
|
*/
|
|
1518
1694
|
export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.env) {
|
|
1519
1695
|
let template;
|
|
@@ -1521,6 +1697,7 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1521
1697
|
let force = false;
|
|
1522
1698
|
let diff = false;
|
|
1523
1699
|
let update = false;
|
|
1700
|
+
let repoint = false;
|
|
1524
1701
|
let register = false;
|
|
1525
1702
|
let greenroom = false;
|
|
1526
1703
|
let greenroomPath;
|
|
@@ -1540,6 +1717,9 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1540
1717
|
else if (arg === "--update") {
|
|
1541
1718
|
update = true;
|
|
1542
1719
|
}
|
|
1720
|
+
else if (arg === "--repoint") {
|
|
1721
|
+
repoint = true;
|
|
1722
|
+
}
|
|
1543
1723
|
else if (arg === "--register") {
|
|
1544
1724
|
register = true;
|
|
1545
1725
|
}
|
|
@@ -1555,7 +1735,7 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1555
1735
|
}
|
|
1556
1736
|
}
|
|
1557
1737
|
if (template === undefined || !TEMPLATES.includes(template)) {
|
|
1558
|
-
streams.err(`usage: ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom [<path>]] [--register]\ntemplates: ${TEMPLATES.join(", ")}\n`);
|
|
1738
|
+
streams.err(`usage: ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom [<path>]] [--register] [--repoint]\ntemplates: ${TEMPLATES.join(", ")}\n`);
|
|
1559
1739
|
return 1;
|
|
1560
1740
|
}
|
|
1561
1741
|
if (diff && force) {
|
|
@@ -1578,7 +1758,10 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1578
1758
|
const srcDir = join(templatesDir(), template);
|
|
1579
1759
|
if (update) {
|
|
1580
1760
|
try {
|
|
1581
|
-
return await applyTemplateUpdate(template, srcDir, repo, streams, env,
|
|
1761
|
+
return await applyTemplateUpdate(template, srcDir, repo, streams, env, {
|
|
1762
|
+
repoint,
|
|
1763
|
+
register,
|
|
1764
|
+
});
|
|
1582
1765
|
}
|
|
1583
1766
|
catch (error) {
|
|
1584
1767
|
streams.err(`init --update failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -1669,7 +1852,7 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1669
1852
|
streams.out("this repo's config and prompts now live outside it; keep that pointer file out of\n" +
|
|
1670
1853
|
"version control (it is host-specific), the same as .ghostrail.env\n");
|
|
1671
1854
|
}
|
|
1672
|
-
await registerGhostrail(repo, env, streams, register);
|
|
1855
|
+
await registerGhostrail(repo, env, streams, { repoint, forceRegister: register });
|
|
1673
1856
|
streams.out(await initNextStepsHint(env, cwd));
|
|
1674
1857
|
return 0;
|
|
1675
1858
|
}
|