@ultimat3/cli 27.14.0 → 27.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "27.14.0",
3
+ "version": "27.15.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "27.14.0",
43
- "@ultimat3/admin": "27.14.0",
44
- "@ultimat3/ai": "27.14.0",
45
- "@ultimat3/auth": "27.14.0",
46
- "@ultimat3/cache": "27.14.0",
47
- "@ultimat3/core": "27.14.0",
48
- "@ultimat3/db": "27.14.0",
49
- "@ultimat3/entity": "27.14.0",
50
- "@ultimat3/flags": "27.14.0",
51
- "@ultimat3/http": "27.14.0",
52
- "@ultimat3/i18n": "27.14.0",
53
- "@ultimat3/jobs": "27.14.0",
54
- "@ultimat3/mail": "27.14.0",
55
- "@ultimat3/manifest": "27.14.0",
56
- "@ultimat3/mcp": "27.14.0",
57
- "@ultimat3/money": "27.14.0",
58
- "@ultimat3/notify": "27.14.0",
59
- "@ultimat3/policy": "27.14.0",
60
- "@ultimat3/pwa": "27.14.0",
61
- "@ultimat3/query": "27.14.0",
62
- "@ultimat3/realtime": "27.14.0",
63
- "@ultimat3/render": "27.14.0",
64
- "@ultimat3/schema": "27.14.0",
65
- "@ultimat3/seo": "27.14.0",
66
- "@ultimat3/storage": "27.14.0",
67
- "@ultimat3/testing": "27.14.0",
68
- "@ultimat3/time": "27.14.0",
42
+ "@ultimat3/action": "27.15.0",
43
+ "@ultimat3/admin": "27.15.0",
44
+ "@ultimat3/ai": "27.15.0",
45
+ "@ultimat3/auth": "27.15.0",
46
+ "@ultimat3/cache": "27.15.0",
47
+ "@ultimat3/core": "27.15.0",
48
+ "@ultimat3/db": "27.15.0",
49
+ "@ultimat3/entity": "27.15.0",
50
+ "@ultimat3/flags": "27.15.0",
51
+ "@ultimat3/http": "27.15.0",
52
+ "@ultimat3/i18n": "27.15.0",
53
+ "@ultimat3/jobs": "27.15.0",
54
+ "@ultimat3/mail": "27.15.0",
55
+ "@ultimat3/manifest": "27.15.0",
56
+ "@ultimat3/mcp": "27.15.0",
57
+ "@ultimat3/money": "27.15.0",
58
+ "@ultimat3/notify": "27.15.0",
59
+ "@ultimat3/policy": "27.15.0",
60
+ "@ultimat3/pwa": "27.15.0",
61
+ "@ultimat3/query": "27.15.0",
62
+ "@ultimat3/realtime": "27.15.0",
63
+ "@ultimat3/render": "27.15.0",
64
+ "@ultimat3/schema": "27.15.0",
65
+ "@ultimat3/seo": "27.15.0",
66
+ "@ultimat3/storage": "27.15.0",
67
+ "@ultimat3/testing": "27.15.0",
68
+ "@ultimat3/time": "27.15.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
package/src/ci-runs.ts CHANGED
@@ -121,11 +121,17 @@ export function latestPerWorkflow(runs: readonly CiRun[]): readonly CiRun[] {
121
121
  return [...newest.values()];
122
122
  }
123
123
 
124
+ /**
125
+ * The branch's runs, newest first. `filters` narrows the ask to one workflow or one event; with
126
+ * neither given, the argv is byte-for-byte the one `x ci` has always sent — cmd-ci.test.ts pins
127
+ * it, so a filter may only ever insert its pair between `--branch` and `--limit`.
128
+ */
124
129
  export async function listRuns(
125
130
  host: GhHost,
126
131
  repo: GhRepo,
127
132
  branch: string,
128
133
  limit: number,
134
+ filters?: { readonly workflow?: string | undefined; readonly event?: string | undefined },
129
135
  ): Promise<readonly CiRun[]> {
130
136
  const rows = await ghJson(
131
137
  host,
@@ -136,6 +142,8 @@ export async function listRuns(
136
142
  repo.slug,
137
143
  '--branch',
138
144
  branch,
145
+ ...(filters?.workflow === undefined ? [] : ['--workflow', filters.workflow]),
146
+ ...(filters?.event === undefined ? [] : ['--event', filters.event]),
139
147
  '--limit',
140
148
  String(limit),
141
149
  '--json',
@@ -6,10 +6,17 @@ import type { CommandSpec } from './parse';
6
6
  /** Log lines kept per failed job. Enough to hold a findings block, short enough to read. */
7
7
  export const TAIL_LINES = 40;
8
8
 
9
+ /**
10
+ * The events `--event` accepts, closed: `cmd-ci.ts` refuses every other value against this same
11
+ * constant, so the summary, the refusal and the `gh` filter can never name different sets.
12
+ */
13
+ export const CI_EVENTS = ['push', 'pull_request', 'workflow_dispatch'] as const;
14
+
9
15
  export const ciSpec: CommandSpec = {
10
16
  name: 'ci',
11
17
  summary: 'the workflow runs for this branch, and the findings inside the failed steps log',
12
- usage: 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--json]',
18
+ usage:
19
+ 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--workflow <name>] [--event <push|pull_request|workflow_dispatch>] [--json]',
13
20
  flags: [
14
21
  { name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
15
22
  { name: 'branch', type: 'string', summary: 'branch to read runs for; this one by default' },
@@ -20,5 +27,15 @@ export const ciSpec: CommandSpec = {
20
27
  summary: `log lines kept per failed job (default ${TAIL_LINES})`,
21
28
  },
22
29
  { name: 'full', type: 'boolean', summary: 'the whole failed-step log, not the tail' },
30
+ {
31
+ name: 'workflow',
32
+ type: 'string',
33
+ summary: 'only this workflow name or filename (e.g. ci.yml)',
34
+ },
35
+ {
36
+ name: 'event',
37
+ type: 'string',
38
+ summary: 'only runs of this event (push, pull_request, workflow_dispatch)',
39
+ },
23
40
  ],
24
41
  };
package/src/cmd-ci.ts CHANGED
@@ -9,8 +9,9 @@ import type { CiLogLine } from './ci-log';
9
9
  import { findingKey, findingsFrom, jobsInLog, parseLogLines, tailOf } from './ci-log';
10
10
  import type { CiJob, CiRun } from './ci-runs';
11
11
  import { failedLog, isFailed, isRunning, latestPerWorkflow, listRuns, viewRun } from './ci-runs';
12
- import { ciSpec, TAIL_LINES } from './cmd-ci-spec';
12
+ import { CI_EVENTS, ciSpec, TAIL_LINES } from './cmd-ci-spec';
13
13
  import type { CliCommand, CommandContext } from './command';
14
+ import { BadFlagError } from './errors';
14
15
  import { parseIntFlag } from './flag-number';
15
16
  import { commentBlock } from './foreign-text';
16
17
  import type { GhRepo } from './gh-target';
@@ -18,6 +19,7 @@ import { currentBranch, resolveRepo } from './gh-target';
18
19
  import { msg } from './messages';
19
20
  import type { CommandResult, Finding, JsonValue } from './output';
20
21
  import { flagBool, flagString } from './parse';
22
+ import { quoteArg } from './shell-quote';
21
23
 
22
24
  export { TAIL_LINES } from './cmd-ci-spec';
23
25
 
@@ -43,10 +45,22 @@ export const RUN_LOOKBACK = 20;
43
45
 
44
46
  /** No run to triage. The remedy is a branch that has one, or the id of the run in question. */
45
47
  export class CiRunNotFoundError extends UltimateError {
46
- constructor(input: { branch: string; repo: string }) {
48
+ constructor(input: {
49
+ branch: string;
50
+ repo: string;
51
+ workflow?: string | undefined;
52
+ event?: string | undefined;
53
+ }) {
54
+ // A filter beside --branch makes an empty list two different situations — a branch never
55
+ // pushed, or a filter that matched nothing. Naming what was asked keeps the diagnosis right.
56
+ const given = [
57
+ input.workflow !== undefined ? `--workflow ${input.workflow}` : undefined,
58
+ input.event !== undefined ? `--event ${input.event}` : undefined,
59
+ ].filter((clause) => clause !== undefined);
60
+ const matching = given.length > 0 ? ` matching ${given.join(' ')}` : '';
47
61
  super({
48
62
  code: 'X_CI_RUN_NOT_FOUND',
49
- cause: `no workflow run on ${input.repo} for branch "${input.branch}"`,
63
+ cause: `no workflow run on ${input.repo} for branch "${input.branch}"${matching}`,
50
64
  fix: `x ci --branch main --repo ${input.repo} --json`,
51
65
  });
52
66
  }
@@ -55,11 +69,39 @@ export class CiRunNotFoundError extends UltimateError {
55
69
  const RUN_FLAG = { name: 'run', command: 'ci', min: 1, example: 'x ci --run 32484583944 --json' };
56
70
  const TAIL_FLAG = { name: 'tail', command: 'ci', min: 1, example: 'x ci --tail 80 --json' };
57
71
 
72
+ /**
73
+ * `--event`, read against the spec's own closed set: a typo is a refusal naming the events that
74
+ * exist, never an empty run list a reader would take for a red branch.
75
+ */
76
+ function readEvent(raw: string | undefined): (typeof CI_EVENTS)[number] | undefined {
77
+ if (raw === undefined) return undefined;
78
+ const known: readonly string[] = CI_EVENTS;
79
+ if (known.includes(raw)) return raw as (typeof CI_EVENTS)[number];
80
+ throw new BadFlagError({
81
+ flag: 'event',
82
+ command: 'ci',
83
+ reason: `${quoteArg(raw)} is not a workflow event — one of ${CI_EVENTS.join(', ')}`,
84
+ fix: 'x ci --event push --json',
85
+ });
86
+ }
87
+
58
88
  export const ciCommand: CliCommand = {
59
89
  spec: ciSpec,
60
90
  async run(ctx: CommandContext): Promise<CommandResult> {
61
- const repo = await resolveRepo(ctx, 'ci', flagString(ctx.args, 'repo'));
91
+ // Filters are validated, and crossed with --run, before any subprocess runs: a refusal must
92
+ // not cost a gh round trip, and a filter beside --run would otherwise be silently ignored.
62
93
  const rawRun = flagString(ctx.args, 'run');
94
+ const workflow = flagString(ctx.args, 'workflow');
95
+ const event = readEvent(flagString(ctx.args, 'event'));
96
+ if (rawRun !== undefined && (workflow !== undefined || event !== undefined)) {
97
+ throw new BadFlagError({
98
+ flag: workflow !== undefined ? 'workflow' : 'event',
99
+ command: 'ci',
100
+ reason: 'filters the run list, and --run names one run already',
101
+ fix: 'x ci --run <id> --json',
102
+ });
103
+ }
104
+ const repo = await resolveRepo(ctx, 'ci', flagString(ctx.args, 'repo'));
63
105
  const rawTail = flagString(ctx.args, 'tail');
64
106
  const limit = flagBool(ctx.args, 'full')
65
107
  ? Number.POSITIVE_INFINITY
@@ -71,8 +113,11 @@ export const ciCommand: CliCommand = {
71
113
  return report(repo, viewed.run.branch, [await inspect(ctx, repo, viewed, limit)]);
72
114
  }
73
115
  const branch = flagString(ctx.args, 'branch') ?? (await currentBranch(ctx));
74
- const runs = latestPerWorkflow(await listRuns(ctx, repo, branch, RUN_LOOKBACK));
75
- if (runs.length === 0) throw new CiRunNotFoundError({ branch, repo: repo.slug });
116
+ const runs = latestPerWorkflow(
117
+ await listRuns(ctx, repo, branch, RUN_LOOKBACK, { workflow, event }),
118
+ );
119
+ if (runs.length === 0)
120
+ throw new CiRunNotFoundError({ branch, repo: repo.slug, workflow, event });
76
121
  const inspected: RunReport[] = [];
77
122
  for (const run of runs) {
78
123
  // Only a failed run is opened. A green run's jobs are 35 rows saying `success`, and fetching
@@ -18,6 +18,8 @@ export const FOLLOW_ONLY_EXPORTS: readonly string[] = [
18
18
  'usePresence',
19
19
  'useConnection',
20
20
  'hasPageSocket',
21
+ // Hears THAT records moved and renders none of them, so a restored store contradicts nothing.
22
+ 'useRecordChanges',
21
23
  'readPresence',
22
24
  'channel',
23
25
  'channelRef',
@@ -37,8 +37,10 @@ const NOTHING: WorkerRetire = Object.freeze({ adopt: () => undefined, disarm: ()
37
37
 
38
38
  /**
39
39
  * Listen for `RETIRE_SIGNAL` NOW, before the boot: Bun's default for an unhandled SIGUSR2 ends the
40
- * process, so a `preStop` that lands while the pod still boots must meet this listener, which waits
41
- * for the worker and then retires it. One retire per process; a second signal joins it.
40
+ * process — except as PID 1 (a container's ENTRYPOINT), where the kernel DROPS a signal with no
41
+ * listener, so a `preStop` that lands before this line runs is lost. The chart's preStop therefore
42
+ * re-sends it every 5 s until PID 1 is gone; this listener waits for the worker and then retires it.
43
+ * One retire per process; a second signal joins it.
42
44
  *
43
45
  * Exits rather than holds, decided 2026-10-07: the `preStop` has no channel back but PID 1 itself,
44
46
  * so "retired" has to be observable as "PID 1 is gone" — and a pod that has stopped claiming serves
@@ -172,7 +172,9 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
172
172
  The worker's retire, opt-in: roles.worker.retireSeconds > 0. The preStop sends PID 1 SIGUSR2 —
173
173
  the framework's retire: stop claiming, finish every held job however long it takes, abort
174
174
  nothing, then drain and exit 0 — and waits until PID 1 is gone, so the SIGTERM that follows finds
175
- nothing left to cut off. bun -e, never kill/sleep: bun is the image's ENTRYPOINT (exec form, so it
175
+ nothing left to cut off. It re-sends the signal every 5 s while it waits: PID 1 has no default
176
+ signal action, so a preStop that lands before the boot arms the listener is dropped by the kernel
177
+ (the pod then idles to its grace); a repeat joins the retire in flight. bun -e, never kill/sleep: bun is the image's ENTRYPOINT (exec form, so it
176
178
  IS PID 1). The kubelet counts the preStop against the grace period, so retireSeconds is added to
177
179
  it and is the longest a retire may take.
178
180
  */}}
@@ -183,7 +185,7 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
183
185
  command:
184
186
  - bun
185
187
  - -e
186
- - "process.kill(1, 'SIGUSR2'); for (;;) { try { process.kill(1, 0); } catch { break; } await Bun.sleep(1000); }"
188
+ - "process.kill(1, 'SIGUSR2'); for (let n = 1; ; n++) { try { process.kill(1, n % 5 === 0 ? 'SIGUSR2' : 0); } catch { break; } await Bun.sleep(1000); }"
187
189
  {{- end }}
188
190
  {{- end }}
189
191
  resources: {{- toYaml $cfg.resources | nindent 4 }}