@descent-vtt/spec-harness 0.7.0 → 0.8.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.
@@ -1 +1 @@
1
- {"version":3,"file":"siblings.js","sourceRoot":"","sources":["../src/siblings.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets spec-brief, spec-graph and spec-guard.\n *\n * Through their command lines and their versioned JSON, never by importing\n * their packages: each is released, pinned and dropped on its own, and none\n * becomes a dependency of this one (spec-core ADR-0005, ADR-0002 here).\n *\n * A sibling is found as the repository installed it -\n * `node_modules/@descent-vtt/<name>/bin/<name>.js`, run with this Node - or\n * as `.spec-harness.json` names it. One that is not there is `absent`, and\n * what needed it says so; nothing it would have checked is reported clean.\n * One installed below this release's minimum is `outdated`, and is reported\n * the same way rather than run (versions.ts).\n */\n\nimport { execFile } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport { parseBriefList, SiblingOutputError } from './briefs.js';\nimport type { HarnessConfig, SiblingName } from './config.js';\nimport type { BriefRow } from './types.js';\nimport { checkVersion } from './versions.js';\n\nexport interface SiblingRun {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\nexport type Sibling =\n /** `version` is `null` for a command `.spec-harness.json` names, whose version is not checked. */\n | { readonly kind: 'found'; readonly command: readonly string[]; readonly version: string | null }\n | { readonly kind: 'absent'; readonly reason: string }\n /** Installed, at a version below the minimum or one that cannot be read, which is `null`. */\n | { readonly kind: 'outdated'; readonly reason: string; readonly version: string | null };\n\n/** Where a sibling is, or why it cannot be run. */\nexport function locate(name: SiblingName, root: string, config: HarnessConfig): Sibling {\n // A command the configuration names is run as named: which package it runs,\n // and so which version, is the configuration's to say.\n const configured = config.tools[name];\n if (configured !== null) return { kind: 'found', command: configured, version: null };\n const directory = join(root, 'node_modules', '@descent-vtt', name);\n const bin = join(directory, 'bin', `${name}.js`);\n if (!existsSync(bin)) {\n return {\n kind: 'absent',\n reason: `${name} is not installed here: npm install --save-dev @descent-vtt/${name}, or name its command under \"tools\" in .spec-harness.json`,\n };\n }\n const check = checkVersion(name, declaredVersion(join(directory, 'package.json')));\n if (!check.ok) return { kind: 'outdated', reason: check.reason, version: check.version };\n return { kind: 'found', command: [process.execPath, bin], version: check.version };\n}\n\n/** The `version` a package.json declares, or `undefined` when there is no such file or it is not JSON. */\nfunction declaredVersion(file: string): unknown {\n try {\n const manifest: unknown = JSON.parse(readFileSync(file, 'utf8'));\n return typeof manifest === 'object' && manifest !== null ? (manifest as { version?: unknown }).version : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Runs a sibling with arguments, in the repository root.\n *\n * Never through a shell: an argument is a path or a brief id, and a shell\n * would read it as syntax. `node` as a command's first word is this Node, so\n * `[\"node\", \"path/to/bin.js\"]` works on every host; a Windows `.cmd` shim such\n * as `npx` cannot be started without a shell, and the error says to name the\n * script instead.\n */\nexport function runSibling(command: readonly string[], args: readonly string[], root: string, timeoutMs = 600_000): Promise<SiblingRun> {\n const [first, ...rest] = command;\n const program = first === 'node' ? process.execPath : (first as string);\n return new Promise((resolve) => {\n execFile(\n program,\n [...rest, ...args],\n {\n cwd: root,\n maxBuffer: 256 * 1024 * 1024,\n encoding: 'utf8',\n windowsHide: true,\n timeout: timeoutMs,\n shell: false,\n env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },\n },\n (error, stdout, stderr) => {\n const failure = error as (Error & { code?: unknown }) | null;\n if (failure !== null && failure.code === 'ENOENT') {\n resolve({\n code: -1,\n stdout: '',\n stderr: `cannot start \"${first}\": name the tool's script, such as [\"node\", \"node_modules/@descent-vtt/<tool>/bin/<tool>.js\"]`,\n });\n return;\n }\n const code = failure === null ? 0 : typeof failure.code === 'number' ? failure.code : -1;\n resolve({ code, stdout, stderr });\n },\n );\n });\n}\n\nexport class SiblingError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SiblingError';\n }\n}\n\nfunction parseJson(name: string, run: SiblingRun): unknown {\n try {\n return JSON.parse(run.stdout);\n } catch {\n const said = (run.stderr.trim() || run.stdout.trim()).split('\\n').slice(0, 3).join(' ');\n throw new SiblingError(`${name} exited ${run.code} without JSON${said ? `: ${said}` : ''}`);\n }\n}\n\nexport interface Siblings {\n readonly root: string;\n locate(name: SiblingName): Sibling;\n /** Every brief, live and archived. Exit 2 from spec-brief is an error here too. */\n briefs(): Promise<BriefRow[]>;\n /** A sibling's JSON output for a command line, with its exit code and what it said on stderr; `absent` says why it cannot be run, missing or outdated. */\n json(name: SiblingName, args: readonly string[]): Promise<{ readonly code: number; readonly document: unknown; readonly stderr: string } | { readonly absent: string }>;\n}\n\nexport function createSiblings(root: string, config: HarnessConfig): Siblings {\n const found = (name: SiblingName): readonly string[] => {\n const sibling = locate(name, root, config);\n if (sibling.kind !== 'found') throw new SiblingError(sibling.reason);\n return sibling.command;\n };\n return {\n root,\n locate: (name) => locate(name, root, config),\n async briefs() {\n const run = await runSibling(found('spec-brief'), ['list', '--archived', '--format', 'json', '--no-color'], root);\n if (run.code === 2) {\n throw new SiblingError(`spec-brief could not list the briefs: ${run.stderr.trim().split('\\n')[0] ?? 'exit 2'}`);\n }\n try {\n return parseBriefList(parseJson('spec-brief', run));\n } catch (error) {\n if (error instanceof SiblingOutputError) throw new SiblingError(error.message);\n throw error;\n }\n },\n async json(name, args) {\n const sibling = locate(name, root, config);\n if (sibling.kind !== 'found') return { absent: sibling.reason };\n const run = await runSibling(sibling.command, args, root);\n return { code: run.code, document: parseJson(name, run), stderr: run.stderr };\n },\n };\n}\n"]}
1
+ {"version":3,"file":"siblings.js","sourceRoot":"","sources":["../src/siblings.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets spec-brief, spec-graph and spec-guard.\n *\n * Through their command lines and their versioned JSON, never by importing\n * their packages: each is released, pinned and dropped on its own, and none\n * becomes a dependency of this one (spec-core ADR-0005, ADR-0002 here).\n *\n * A sibling is found as the repository installed it -\n * `node_modules/@descent-vtt/<name>/bin/<name>.js`, run with this Node - or\n * as `.spec-harness.json` names it. One that is not there is `absent`, and\n * what needed it says so; nothing it would have checked is reported clean.\n * One installed below this release's minimum is `outdated`, and is reported\n * the same way rather than run (versions.ts).\n */\n\nimport { execFile } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport { parseBriefList, SiblingOutputError } from './briefs.js';\nimport type { HarnessConfig, SiblingName } from './config.js';\nimport type { BriefRow } from './types.js';\nimport { checkVersion } from './versions.js';\n\nexport interface SiblingRun {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\nexport type Sibling =\n /** `version` is `null` for a command `.spec-harness.json` names, whose version is not checked. */\n | { readonly kind: 'found'; readonly command: readonly string[]; readonly version: string | null }\n | { readonly kind: 'absent'; readonly reason: string }\n /** Installed, at a version below the minimum or one that cannot be read, which is `null`. */\n | { readonly kind: 'outdated'; readonly reason: string; readonly version: string | null };\n\n/** Where a sibling is, or why it cannot be run. */\nexport function locate(name: SiblingName, root: string, config: HarnessConfig): Sibling {\n // A command the configuration names is run as named: which package it runs,\n // and so which version, is the configuration's to say.\n const configured = config.tools[name];\n if (configured !== null) return { kind: 'found', command: configured, version: null };\n const directory = join(root, 'node_modules', '@descent-vtt', name);\n const bin = join(directory, 'bin', `${name}.js`);\n if (!existsSync(bin)) {\n return {\n kind: 'absent',\n reason: `${name} is not installed here: npm install --save-dev @descent-vtt/${name}, or name its command under \"tools\" in .spec-harness.json`,\n };\n }\n const check = checkVersion(name, declaredVersion(join(directory, 'package.json')));\n if (!check.ok) return { kind: 'outdated', reason: check.reason, version: check.version };\n return { kind: 'found', command: [process.execPath, bin], version: check.version };\n}\n\n/** The `version` a package.json declares, or `undefined` when there is no such file or it is not JSON. */\nexport function declaredVersion(file: string): unknown {\n try {\n const manifest: unknown = JSON.parse(readFileSync(file, 'utf8'));\n return typeof manifest === 'object' && manifest !== null ? (manifest as { version?: unknown }).version : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Runs a sibling with arguments, in the repository root.\n *\n * Never through a shell: an argument is a path or a brief id, and a shell\n * would read it as syntax. `node` as a command's first word is this Node, so\n * `[\"node\", \"path/to/bin.js\"]` works on every host; a Windows `.cmd` shim such\n * as `npx` cannot be started without a shell, and the error says to name the\n * script instead.\n */\nexport function runSibling(command: readonly string[], args: readonly string[], root: string, timeoutMs = 600_000): Promise<SiblingRun> {\n const [first, ...rest] = command;\n const program = first === 'node' ? process.execPath : (first as string);\n return new Promise((resolve) => {\n execFile(\n program,\n [...rest, ...args],\n {\n cwd: root,\n maxBuffer: 256 * 1024 * 1024,\n encoding: 'utf8',\n windowsHide: true,\n timeout: timeoutMs,\n shell: false,\n env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },\n },\n (error, stdout, stderr) => {\n const failure = error as (Error & { code?: unknown }) | null;\n if (failure !== null && failure.code === 'ENOENT') {\n resolve({\n code: -1,\n stdout: '',\n stderr: `cannot start \"${first}\": name the tool's script, such as [\"node\", \"node_modules/@descent-vtt/<tool>/bin/<tool>.js\"]`,\n });\n return;\n }\n const code = failure === null ? 0 : typeof failure.code === 'number' ? failure.code : -1;\n resolve({ code, stdout, stderr });\n },\n );\n });\n}\n\nexport class SiblingError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SiblingError';\n }\n}\n\nfunction parseJson(name: string, run: SiblingRun): unknown {\n try {\n return JSON.parse(run.stdout);\n } catch {\n const said = (run.stderr.trim() || run.stdout.trim()).split('\\n').slice(0, 3).join(' ');\n throw new SiblingError(`${name} exited ${run.code} without JSON${said ? `: ${said}` : ''}`);\n }\n}\n\nexport interface Siblings {\n readonly root: string;\n locate(name: SiblingName): Sibling;\n /** Every brief, live and archived. Exit 2 from spec-brief is an error here too. */\n briefs(): Promise<BriefRow[]>;\n /** A sibling's JSON output for a command line, with its exit code and what it said on stderr; `absent` says why it cannot be run, missing or outdated. */\n json(name: SiblingName, args: readonly string[]): Promise<{ readonly code: number; readonly document: unknown; readonly stderr: string } | { readonly absent: string }>;\n}\n\nexport function createSiblings(root: string, config: HarnessConfig): Siblings {\n const found = (name: SiblingName): readonly string[] => {\n const sibling = locate(name, root, config);\n if (sibling.kind !== 'found') throw new SiblingError(sibling.reason);\n return sibling.command;\n };\n return {\n root,\n locate: (name) => locate(name, root, config),\n async briefs() {\n const run = await runSibling(found('spec-brief'), ['list', '--archived', '--format', 'json', '--no-color'], root);\n if (run.code === 2) {\n throw new SiblingError(`spec-brief could not list the briefs: ${run.stderr.trim().split('\\n')[0] ?? 'exit 2'}`);\n }\n try {\n return parseBriefList(parseJson('spec-brief', run));\n } catch (error) {\n if (error instanceof SiblingOutputError) throw new SiblingError(error.message);\n throw error;\n }\n },\n async json(name, args) {\n const sibling = locate(name, root, config);\n if (sibling.kind !== 'found') return { absent: sibling.reason };\n const run = await runSibling(sibling.command, args, root);\n return { code: run.code, document: parseJson(name, run), stderr: run.stderr };\n },\n };\n}\n"]}
package/dist/signers.d.ts CHANGED
@@ -3,11 +3,14 @@
3
3
  * SIGNERS), to say which signers sign with a key that is not FIDO2.
4
4
  *
5
5
  * A line is `principals [options] keytype key [comment]`: principals a
6
- * comma-separated list, quoted or not; options a comma-separated list with no
7
- * space outside quotes, such as `cert-authority`, `namespaces="git"` or
8
- * `valid-after="20260101"`. A `cert-authority` line holds the key of a
9
- * certificate authority, not a person's: the keys it certifies are not in the
10
- * file, so their type cannot be read from it.
6
+ * comma-separated list, which may hold a double-quoted part; options a
7
+ * comma-separated list with no space outside quotes, such as
8
+ * `cert-authority`, `namespaces="git"` or `valid-after="20260101"`, each
9
+ * value in double quotes. The quotes are read as OpenSSH's sshsig.c and
10
+ * misc.c read them, and each function below names the one it follows. A
11
+ * `cert-authority` line holds the key of a certificate authority, not a
12
+ * person's: the keys it certifies are not in the file, so their type cannot
13
+ * be read from it.
11
14
  *
12
15
  * Pure: doctor reads the file from the base and passes the text.
13
16
  */
package/dist/signers.js CHANGED
@@ -3,11 +3,14 @@
3
3
  * SIGNERS), to say which signers sign with a key that is not FIDO2.
4
4
  *
5
5
  * A line is `principals [options] keytype key [comment]`: principals a
6
- * comma-separated list, quoted or not; options a comma-separated list with no
7
- * space outside quotes, such as `cert-authority`, `namespaces="git"` or
8
- * `valid-after="20260101"`. A `cert-authority` line holds the key of a
9
- * certificate authority, not a person's: the keys it certifies are not in the
10
- * file, so their type cannot be read from it.
6
+ * comma-separated list, which may hold a double-quoted part; options a
7
+ * comma-separated list with no space outside quotes, such as
8
+ * `cert-authority`, `namespaces="git"` or `valid-after="20260101"`, each
9
+ * value in double quotes. The quotes are read as OpenSSH's sshsig.c and
10
+ * misc.c read them, and each function below names the one it follows. A
11
+ * `cert-authority` line holds the key of a certificate authority, not a
12
+ * person's: the keys it certifies are not in the file, so their type cannot
13
+ * be read from it.
11
14
  *
12
15
  * Pure: doctor reads the file from the base and passes the text.
13
16
  */
@@ -21,38 +24,99 @@ export const FIDO2_KEY_TYPES = ['sk-ssh-ed25519@openssh.com', 'sk-ecdsa-sha2-nis
21
24
  * type OpenSSH adds later is read as one, as OpenSSH reads it.
22
25
  */
23
26
  const KEY = /^AAAA[A-Za-z0-9+/]+={0,2}$/;
24
- /** The next field and what follows it: up to the first space outside double quotes. */
25
- function field(text) {
27
+ /** What OpenSSH's `strdelim` skips as whitespace between fields. */
28
+ const WHITESPACE = /^[ \t\r\n]*/;
29
+ /**
30
+ * The principals field, as OpenSSH's `strdelimw` reads it: up to the first
31
+ * whitespace or double quote. A quote there, at the start or in the middle,
32
+ * is taken out with the next one, and the field ends at that one whatever
33
+ * follows it: `a@example.com,"b@example.com"` is
34
+ * `a@example.com,b@example.com`, and `"a"b` is `a`, with `b` the next field.
35
+ * `null` when the quote is never closed, which makes the line no signer.
36
+ */
37
+ function principalsField(text) {
38
+ const at = text.search(/[ \t\r\n"]/);
39
+ if (at === -1)
40
+ return { value: text, rest: '' };
41
+ if (text.charAt(at) !== '"')
42
+ return { value: text.slice(0, at), rest: text.slice(at).replace(WHITESPACE, '') };
43
+ const close = text.indexOf('"', at + 1);
44
+ if (close === -1)
45
+ return null;
46
+ return { value: text.slice(0, at) + text.slice(at + 1, close), rest: text.slice(close + 1).replace(WHITESPACE, '') };
47
+ }
48
+ /**
49
+ * The options field, as OpenSSH's `sshkey_advance_past_options` finds its
50
+ * end: the first space or tab outside double quotes, where `\"` is a quote
51
+ * inside them. `null` when a quote is never closed.
52
+ */
53
+ function optionsField(text) {
54
+ // Reading one character past the end changes nothing - `charAt` gives the
55
+ // empty string there - so `<=` for `<` is equivalent.
26
56
  let quoted = false;
27
57
  let i = 0;
28
- for (; i < text.length; i += 1) {
29
- const ch = text.charAt(i);
30
- if (ch === '"')
58
+ for (; i < text.length && (quoted || (text.charAt(i) !== ' ' && text.charAt(i) !== '\t')); i += 1) {
59
+ if (text.charAt(i) === '\\' && text.charAt(i + 1) === '"')
60
+ i += 1;
61
+ else if (text.charAt(i) === '"')
31
62
  quoted = !quoted;
32
- else if (!quoted && (ch === ' ' || ch === '\t'))
33
- break;
34
63
  }
35
- return { value: text.slice(0, i), rest: text.slice(i).trimStart() };
64
+ if (quoted)
65
+ return null;
66
+ return { value: text.slice(0, i), rest: text.slice(i).replace(/^[ \t]*/, '') };
36
67
  }
37
- /** Splits on commas outside double quotes. */
38
- function commas(text) {
39
- const parts = [];
40
- let quoted = false;
41
- let start = 0;
42
- for (let i = 0; i < text.length; i += 1) {
43
- const ch = text.charAt(i);
44
- if (ch === '"')
45
- quoted = !quoted;
46
- else if (ch === ',' && !quoted) {
47
- parts.push(text.slice(start, i));
48
- start = i + 1;
68
+ /** A key type or a key: up to the next space or tab. */
69
+ function word(text) {
70
+ const [, value, rest] = /^([^ \t]*)[ \t]*(.*)$/s.exec(text);
71
+ return { value: value, rest: rest };
72
+ }
73
+ const NO_OPTIONS = { certAuthority: false, namespaces: null, validAfter: null, validBefore: null };
74
+ /** The options with a value, each of which OpenSSH takes once. */
75
+ const VALUED = ['namespaces', 'valid-after', 'valid-before'];
76
+ /**
77
+ * The options, as OpenSSH's `sshsigopt_parse` reads them: `cert-authority`
78
+ * alone and `namespaces`, `valid-after` and `valid-before` with a value,
79
+ * separated by commas, a name in any case and a value always in double
80
+ * quotes, `\"` a quote inside one (its `opt_dequote`). Anything else - another
81
+ * option, a value without its quotes or given twice, anything but a comma
82
+ * after an option, a comma with nothing after it - makes the line no signer,
83
+ * as it does for OpenSSH. The times are read as written.
84
+ */
85
+ function readOptions(text) {
86
+ const values = new Map();
87
+ let certAuthority = false;
88
+ let at = 0;
89
+ while (at < text.length) {
90
+ const name = /^[^,="]*/.exec(text.slice(at))[0];
91
+ const key = name.toLowerCase();
92
+ at += name.length;
93
+ const valued = text.charAt(at) === '=';
94
+ if (key === 'cert-authority' && !valued) {
95
+ certAuthority = true;
96
+ }
97
+ else if (VALUED.includes(key) && valued) {
98
+ // `optionsField` has refused a quote never closed, so a value that
99
+ // opens one closes it.
100
+ const quoted = /^"((?:\\"|[^"])*)"/.exec(text.slice(at + 1));
101
+ if (quoted === null)
102
+ return { refused: `the value of ${name} is not in double quotes, as OpenSSH needs: write ${name}="..."` };
103
+ if (values.has(key))
104
+ return { refused: `it gives ${key} twice, which OpenSSH refuses` };
105
+ values.set(key, quoted[1].replace(/\\"/g, '"'));
106
+ at += 1 + quoted[0].length;
107
+ }
108
+ else {
109
+ return { refused: 'OpenSSH reads only cert-authority, namespaces="...", valid-after="..." and valid-before="..." as options' };
49
110
  }
111
+ if (at === text.length)
112
+ break;
113
+ if (text.charAt(at) !== ',')
114
+ return { refused: 'its options are not separated by commas' };
115
+ at += 1;
116
+ if (at === text.length)
117
+ return { refused: 'its options end in a comma' };
50
118
  }
51
- parts.push(text.slice(start));
52
- return parts;
53
- }
54
- function unquote(value) {
55
- return value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
119
+ return { certAuthority, namespaces: values.get('namespaces') ?? null, validAfter: values.get('valid-after') ?? null, validBefore: values.get('valid-before') ?? null };
56
120
  }
57
121
  /** Reads the file's text: every signer, and every line that is not one. */
58
122
  export function readAllowedSigners(text) {
@@ -63,32 +127,43 @@ export function readAllowedSigners(text) {
63
127
  const content = raw.trim();
64
128
  if (content === '' || content.startsWith('#'))
65
129
  return;
66
- const principals = field(content);
67
- let type = field(principals.rest);
68
- let options = [];
69
- if (!KEY.test(field(type.rest).value)) {
70
- options = commas(type.value);
71
- type = field(type.rest);
72
- }
73
- const key = field(type.rest).value;
74
- if (type.value === '' || !KEY.test(key)) {
75
- problems.push({ line, message: 'a signer is its principals, any options, a key type and a key' });
76
- return;
77
- }
78
- const option = (name) => {
79
- const found = options.find((item) => item.toLowerCase().startsWith(`${name}=`));
80
- return found === undefined ? null : unquote(found.slice(name.length + 1));
130
+ const refuse = (message) => {
131
+ problems.push({ line, message });
81
132
  };
82
- const namespaces = option('namespaces');
133
+ const principals = principalsField(content);
134
+ if (principals === null)
135
+ return refuse('its principals open a double quote that is never closed');
136
+ // OpenSSH reads a key where the key type would be, and options when it
137
+ // cannot: a key type is followed by a key, and options are not.
138
+ let type = word(principals.rest);
139
+ let written = null;
140
+ if (!KEY.test(word(type.rest).value)) {
141
+ const field = optionsField(principals.rest);
142
+ if (field === null)
143
+ return refuse('its options open a double quote that is never closed');
144
+ written = field.value;
145
+ type = word(field.rest);
146
+ }
147
+ const key = word(type.rest).value;
148
+ // With no key type, nothing follows it either, and no key is a key.
149
+ if (!KEY.test(key))
150
+ return refuse('a signer is its principals, any options, a key type and a key');
151
+ // OpenSSH reads the key before the options, and says first what is wrong with it.
152
+ const options = written === null ? NO_OPTIONS : readOptions(written);
153
+ if ('refused' in options)
154
+ return refuse(options.refused);
83
155
  signers.push({
84
156
  line,
85
- principals: commas(unquote(principals.value))
157
+ // The field keeps no quote, so every comma parts two principals, as
158
+ // OpenSSH's pattern lists read them.
159
+ principals: principals.value
160
+ .split(',')
86
161
  .map((principal) => principal.trim())
87
162
  .filter((principal) => principal !== ''),
88
- certAuthority: options.some((item) => item.toLowerCase() === 'cert-authority'),
89
- namespaces: namespaces === null ? null : namespaces.split(',').map((name) => name.trim()),
90
- validAfter: option('valid-after'),
91
- validBefore: option('valid-before'),
163
+ certAuthority: options.certAuthority,
164
+ namespaces: options.namespaces === null ? null : options.namespaces.split(',').map((name) => name.trim()),
165
+ validAfter: options.validAfter,
166
+ validBefore: options.validBefore,
92
167
  keyType: type.value,
93
168
  key,
94
169
  });
@@ -1 +1 @@
1
- {"version":3,"file":"signers.js","sourceRoot":"","sources":["../src/signers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The allowed-signers file, read as OpenSSH reads it (ssh-keygen(1), ALLOWED\n * SIGNERS), to say which signers sign with a key that is not FIDO2.\n *\n * A line is `principals [options] keytype key [comment]`: principals a\n * comma-separated list, quoted or not; options a comma-separated list with no\n * space outside quotes, such as `cert-authority`, `namespaces=\"git\"` or\n * `valid-after=\"20260101\"`. A `cert-authority` line holds the key of a\n * certificate authority, not a person's: the keys it certifies are not in the\n * file, so their type cannot be read from it.\n *\n * Pure: doctor reads the file from the base and passes the text.\n */\n\n/** The key types of a FIDO2 security key: a signature needs a touch no process can supply (ADR-0006). */\nexport const FIDO2_KEY_TYPES: readonly string[] = ['sk-ssh-ed25519@openssh.com', 'sk-ecdsa-sha2-nistp256@openssh.com'];\n\nexport interface AllowedSigner {\n /** 1-based. */\n readonly line: number;\n readonly principals: readonly string[];\n /** Whether the key is a certificate authority's. */\n readonly certAuthority: boolean;\n /** The namespaces the key may sign in, or `null` for any. */\n readonly namespaces: readonly string[] | null;\n readonly validAfter: string | null;\n readonly validBefore: string | null;\n readonly keyType: string;\n readonly key: string;\n}\n\nexport interface SignersRead {\n readonly signers: readonly AllowedSigner[];\n /** Lines that are not a signer, with why. */\n readonly problems: readonly { readonly line: number; readonly message: string }[];\n}\n\n/**\n * A key as the file writes it: base64 of the key's wire form, which starts\n * with the four-byte length of its type's name, so with `AAAA`. OpenSSH tells\n * options from a key type by trying to read a key after it; a key type is\n * followed by a key, and options by a key type, which is not one. So a key\n * type OpenSSH adds later is read as one, as OpenSSH reads it.\n */\nconst KEY = /^AAAA[A-Za-z0-9+/]+={0,2}$/;\n\n/** The next field and what follows it: up to the first space outside double quotes. */\nfunction field(text: string): { value: string; rest: string } {\n let quoted = false;\n let i = 0;\n for (; i < text.length; i += 1) {\n const ch = text.charAt(i);\n if (ch === '\"') quoted = !quoted;\n else if (!quoted && (ch === ' ' || ch === '\\t')) break;\n }\n return { value: text.slice(0, i), rest: text.slice(i).trimStart() };\n}\n\n/** Splits on commas outside double quotes. */\nfunction commas(text: string): string[] {\n const parts: string[] = [];\n let quoted = false;\n let start = 0;\n for (let i = 0; i < text.length; i += 1) {\n const ch = text.charAt(i);\n if (ch === '\"') quoted = !quoted;\n else if (ch === ',' && !quoted) {\n parts.push(text.slice(start, i));\n start = i + 1;\n }\n }\n parts.push(text.slice(start));\n return parts;\n}\n\nfunction unquote(value: string): string {\n return value.length >= 2 && value.startsWith('\"') && value.endsWith('\"') ? value.slice(1, -1) : value;\n}\n\n/** Reads the file's text: every signer, and every line that is not one. */\nexport function readAllowedSigners(text: string): SignersRead {\n const signers: AllowedSigner[] = [];\n const problems: { line: number; message: string }[] = [];\n text.split(/\\r?\\n/).forEach((raw, index) => {\n const line = index + 1;\n const content = raw.trim();\n if (content === '' || content.startsWith('#')) return;\n const principals = field(content);\n let type = field(principals.rest);\n let options: string[] = [];\n if (!KEY.test(field(type.rest).value)) {\n options = commas(type.value);\n type = field(type.rest);\n }\n const key = field(type.rest).value;\n if (type.value === '' || !KEY.test(key)) {\n problems.push({ line, message: 'a signer is its principals, any options, a key type and a key' });\n return;\n }\n const option = (name: string): string | null => {\n const found = options.find((item) => item.toLowerCase().startsWith(`${name}=`));\n return found === undefined ? null : unquote(found.slice(name.length + 1));\n };\n const namespaces = option('namespaces');\n signers.push({\n line,\n principals: commas(unquote(principals.value))\n .map((principal) => principal.trim())\n .filter((principal) => principal !== ''),\n certAuthority: options.some((item) => item.toLowerCase() === 'cert-authority'),\n namespaces: namespaces === null ? null : namespaces.split(',').map((name) => name.trim()),\n validAfter: option('valid-after'),\n validBefore: option('valid-before'),\n keyType: type.value,\n key,\n });\n });\n return { signers, problems };\n}\n\n/**\n * The signers whose own key is not a FIDO2 security key's. A certificate\n * authority's line is left out: the keys it vouches for are not in the file.\n */\nexport function notFido2(signers: readonly AllowedSigner[]): AllowedSigner[] {\n return signers.filter((signer) => !signer.certAuthority && !FIDO2_KEY_TYPES.includes(signer.keyType));\n}\n"]}
1
+ {"version":3,"file":"signers.js","sourceRoot":"","sources":["../src/signers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The allowed-signers file, read as OpenSSH reads it (ssh-keygen(1), ALLOWED\n * SIGNERS), to say which signers sign with a key that is not FIDO2.\n *\n * A line is `principals [options] keytype key [comment]`: principals a\n * comma-separated list, which may hold a double-quoted part; options a\n * comma-separated list with no space outside quotes, such as\n * `cert-authority`, `namespaces=\"git\"` or `valid-after=\"20260101\"`, each\n * value in double quotes. The quotes are read as OpenSSH's sshsig.c and\n * misc.c read them, and each function below names the one it follows. A\n * `cert-authority` line holds the key of a certificate authority, not a\n * person's: the keys it certifies are not in the file, so their type cannot\n * be read from it.\n *\n * Pure: doctor reads the file from the base and passes the text.\n */\n\n/** The key types of a FIDO2 security key: a signature needs a touch no process can supply (ADR-0006). */\nexport const FIDO2_KEY_TYPES: readonly string[] = ['sk-ssh-ed25519@openssh.com', 'sk-ecdsa-sha2-nistp256@openssh.com'];\n\nexport interface AllowedSigner {\n /** 1-based. */\n readonly line: number;\n readonly principals: readonly string[];\n /** Whether the key is a certificate authority's. */\n readonly certAuthority: boolean;\n /** The namespaces the key may sign in, or `null` for any. */\n readonly namespaces: readonly string[] | null;\n readonly validAfter: string | null;\n readonly validBefore: string | null;\n readonly keyType: string;\n readonly key: string;\n}\n\nexport interface SignersRead {\n readonly signers: readonly AllowedSigner[];\n /** Lines that are not a signer, with why. */\n readonly problems: readonly { readonly line: number; readonly message: string }[];\n}\n\n/**\n * A key as the file writes it: base64 of the key's wire form, which starts\n * with the four-byte length of its type's name, so with `AAAA`. OpenSSH tells\n * options from a key type by trying to read a key after it; a key type is\n * followed by a key, and options by a key type, which is not one. So a key\n * type OpenSSH adds later is read as one, as OpenSSH reads it.\n */\nconst KEY = /^AAAA[A-Za-z0-9+/]+={0,2}$/;\n\n/** What OpenSSH's `strdelim` skips as whitespace between fields. */\nconst WHITESPACE = /^[ \\t\\r\\n]*/;\n\n/**\n * The principals field, as OpenSSH's `strdelimw` reads it: up to the first\n * whitespace or double quote. A quote there, at the start or in the middle,\n * is taken out with the next one, and the field ends at that one whatever\n * follows it: `a@example.com,\"b@example.com\"` is\n * `a@example.com,b@example.com`, and `\"a\"b` is `a`, with `b` the next field.\n * `null` when the quote is never closed, which makes the line no signer.\n */\nfunction principalsField(text: string): { value: string; rest: string } | null {\n const at = text.search(/[ \\t\\r\\n\"]/);\n if (at === -1) return { value: text, rest: '' };\n if (text.charAt(at) !== '\"') return { value: text.slice(0, at), rest: text.slice(at).replace(WHITESPACE, '') };\n const close = text.indexOf('\"', at + 1);\n if (close === -1) return null;\n return { value: text.slice(0, at) + text.slice(at + 1, close), rest: text.slice(close + 1).replace(WHITESPACE, '') };\n}\n\n/**\n * The options field, as OpenSSH's `sshkey_advance_past_options` finds its\n * end: the first space or tab outside double quotes, where `\\\"` is a quote\n * inside them. `null` when a quote is never closed.\n */\nfunction optionsField(text: string): { value: string; rest: string } | null {\n // Reading one character past the end changes nothing - `charAt` gives the\n // empty string there - so `<=` for `<` is equivalent.\n let quoted = false;\n let i = 0;\n for (; i < text.length && (quoted || (text.charAt(i) !== ' ' && text.charAt(i) !== '\\t')); i += 1) {\n if (text.charAt(i) === '\\\\' && text.charAt(i + 1) === '\"') i += 1;\n else if (text.charAt(i) === '\"') quoted = !quoted;\n }\n if (quoted) return null;\n return { value: text.slice(0, i), rest: text.slice(i).replace(/^[ \\t]*/, '') };\n}\n\n/** A key type or a key: up to the next space or tab. */\nfunction word(text: string): { value: string; rest: string } {\n const [, value, rest] = /^([^ \\t]*)[ \\t]*(.*)$/s.exec(text) as RegExpExecArray;\n return { value: value as string, rest: rest as string };\n}\n\ninterface Options {\n readonly certAuthority: boolean;\n readonly namespaces: string | null;\n readonly validAfter: string | null;\n readonly validBefore: string | null;\n}\n\nconst NO_OPTIONS: Options = { certAuthority: false, namespaces: null, validAfter: null, validBefore: null };\n\n/** The options with a value, each of which OpenSSH takes once. */\nconst VALUED = ['namespaces', 'valid-after', 'valid-before'];\n\n/**\n * The options, as OpenSSH's `sshsigopt_parse` reads them: `cert-authority`\n * alone and `namespaces`, `valid-after` and `valid-before` with a value,\n * separated by commas, a name in any case and a value always in double\n * quotes, `\\\"` a quote inside one (its `opt_dequote`). Anything else - another\n * option, a value without its quotes or given twice, anything but a comma\n * after an option, a comma with nothing after it - makes the line no signer,\n * as it does for OpenSSH. The times are read as written.\n */\nfunction readOptions(text: string): Options | { refused: string } {\n const values = new Map<string, string>();\n let certAuthority = false;\n let at = 0;\n while (at < text.length) {\n const name = (/^[^,=\"]*/.exec(text.slice(at)) as RegExpExecArray)[0];\n const key = name.toLowerCase();\n at += name.length;\n const valued = text.charAt(at) === '=';\n if (key === 'cert-authority' && !valued) {\n certAuthority = true;\n } else if (VALUED.includes(key) && valued) {\n // `optionsField` has refused a quote never closed, so a value that\n // opens one closes it.\n const quoted = /^\"((?:\\\\\"|[^\"])*)\"/.exec(text.slice(at + 1));\n if (quoted === null) return { refused: `the value of ${name} is not in double quotes, as OpenSSH needs: write ${name}=\"...\"` };\n if (values.has(key)) return { refused: `it gives ${key} twice, which OpenSSH refuses` };\n values.set(key, (quoted[1] as string).replace(/\\\\\"/g, '\"'));\n at += 1 + quoted[0].length;\n } else {\n return { refused: 'OpenSSH reads only cert-authority, namespaces=\"...\", valid-after=\"...\" and valid-before=\"...\" as options' };\n }\n if (at === text.length) break;\n if (text.charAt(at) !== ',') return { refused: 'its options are not separated by commas' };\n at += 1;\n if (at === text.length) return { refused: 'its options end in a comma' };\n }\n return { certAuthority, namespaces: values.get('namespaces') ?? null, validAfter: values.get('valid-after') ?? null, validBefore: values.get('valid-before') ?? null };\n}\n\n/** Reads the file's text: every signer, and every line that is not one. */\nexport function readAllowedSigners(text: string): SignersRead {\n const signers: AllowedSigner[] = [];\n const problems: { line: number; message: string }[] = [];\n text.split(/\\r?\\n/).forEach((raw, index) => {\n const line = index + 1;\n const content = raw.trim();\n if (content === '' || content.startsWith('#')) return;\n const refuse = (message: string): void => {\n problems.push({ line, message });\n };\n const principals = principalsField(content);\n if (principals === null) return refuse('its principals open a double quote that is never closed');\n // OpenSSH reads a key where the key type would be, and options when it\n // cannot: a key type is followed by a key, and options are not.\n let type = word(principals.rest);\n let written: string | null = null;\n if (!KEY.test(word(type.rest).value)) {\n const field = optionsField(principals.rest);\n if (field === null) return refuse('its options open a double quote that is never closed');\n written = field.value;\n type = word(field.rest);\n }\n const key = word(type.rest).value;\n // With no key type, nothing follows it either, and no key is a key.\n if (!KEY.test(key)) return refuse('a signer is its principals, any options, a key type and a key');\n // OpenSSH reads the key before the options, and says first what is wrong with it.\n const options = written === null ? NO_OPTIONS : readOptions(written);\n if ('refused' in options) return refuse(options.refused);\n signers.push({\n line,\n // The field keeps no quote, so every comma parts two principals, as\n // OpenSSH's pattern lists read them.\n principals: principals.value\n .split(',')\n .map((principal) => principal.trim())\n .filter((principal) => principal !== ''),\n certAuthority: options.certAuthority,\n namespaces: options.namespaces === null ? null : options.namespaces.split(',').map((name) => name.trim()),\n validAfter: options.validAfter,\n validBefore: options.validBefore,\n keyType: type.value,\n key,\n });\n });\n return { signers, problems };\n}\n\n/**\n * The signers whose own key is not a FIDO2 security key's. A certificate\n * authority's line is left out: the keys it vouches for are not in the file.\n */\nexport function notFido2(signers: readonly AllowedSigner[]): AllowedSigner[] {\n return signers.filter((signer) => !signer.certAuthority && !FIDO2_KEY_TYPES.includes(signer.keyType));\n}\n"]}
@@ -49,20 +49,33 @@ export declare function checkVersion(name: SiblingName, declared: unknown): Vers
49
49
  * write passes unguarded.
50
50
  */
51
51
  export declare const CLAUDE_CODE_MINIMUM = "2.1.139";
52
- /** What `claude --version` printed, or why nothing could be asked. */
52
+ /**
53
+ * What `claude --version` printed; the version the package.json of the
54
+ * Claude Code a Windows shim runs declares, which is `file`; or why neither
55
+ * could be had.
56
+ */
53
57
  export type ClaudeCodeAnswer = {
54
58
  readonly output: string;
59
+ } | {
60
+ readonly declared: string;
61
+ readonly file: string;
55
62
  } | {
56
63
  readonly missing: string;
57
64
  };
58
- export type ClaudeCodeCheck = {
65
+ export type ClaudeCodeCheck =
66
+ /** `file` is the package.json the version was read from, when a shim was read rather than claude run. */
67
+ {
59
68
  readonly state: 'ok' | 'outdated';
60
69
  readonly version: string;
70
+ readonly file?: string;
61
71
  }
62
72
  /** Never read as fine: an older release lets every write through. */
63
73
  | {
64
74
  readonly state: 'unknown';
65
75
  readonly reason: string;
66
76
  };
67
- /** The release `claude --version` names in its first word, `2.1.235 (Claude Code)`, against the minimum. */
77
+ /**
78
+ * The release `claude --version` names in its first word, `2.1.235 (Claude
79
+ * Code)`, or the one a package.json declares, against the minimum.
80
+ */
68
81
  export declare function checkClaudeCode(answer: ClaudeCodeAnswer): ClaudeCodeCheck;
package/dist/versions.js CHANGED
@@ -63,11 +63,21 @@ export function checkVersion(name, declared) {
63
63
  * write passes unguarded.
64
64
  */
65
65
  export const CLAUDE_CODE_MINIMUM = '2.1.139';
66
- /** The release `claude --version` names in its first word, `2.1.235 (Claude Code)`, against the minimum. */
66
+ /**
67
+ * The release `claude --version` names in its first word, `2.1.235 (Claude
68
+ * Code)`, or the one a package.json declares, against the minimum.
69
+ */
67
70
  export function checkClaudeCode(answer) {
68
71
  if ('missing' in answer)
69
72
  return { state: 'unknown', reason: answer.missing };
70
- const said = answer.output.trim().split('\n')[0]?.trim() ?? '';
73
+ if ('declared' in answer) {
74
+ const declared = parseVersion(answer.declared);
75
+ if (declared === null)
76
+ return { state: 'unknown', reason: `${answer.file} declares "${answer.declared}", which is not a version` };
77
+ return { state: meets(declared, parseVersion(CLAUDE_CODE_MINIMUM)) ? 'ok' : 'outdated', version: answer.declared, file: answer.file };
78
+ }
79
+ // split() gives at least one string, so the first line is always there.
80
+ const said = answer.output.trim().split('\n')[0].trim();
71
81
  if (said === '')
72
82
  return { state: 'unknown', reason: 'claude --version printed nothing' };
73
83
  const first = said.split(/\s+/)[0];
@@ -1 +1 @@
1
- {"version":3,"file":"versions.js","sourceRoot":"","sources":["../src/versions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,MAAM,CAAC,MAAM,gBAAgB,GAA0C;IACrE,wEAAwE;IACxE,8CAA8C;IAC9C,YAAY,EAAE,OAAO;IACrB,4EAA4E;IAC5E,yEAAyE;IACzE,YAAY,EAAE,OAAO;IACrB,2EAA2E;IAC3E,wDAAwD;IACxD,YAAY,EAAE,QAAQ;CACvB,CAAC;AASF,qGAAqG;AACrG,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,MAAM,KAAK,GAAG,yHAAyH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnJ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAChC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;AAC3H,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,KAAK,CAAC,SAAkB,EAAE,OAAgB;IACxD,MAAM,KAAK,GAAG,CAAC,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,EAAE,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,EAAE,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,UAAU,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC;IAC9J,OAAO,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;AAC7D,CAAC;AAMD;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,IAAiB,EAAE,QAAiB;IAC/D,MAAM,OAAO,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IACvC,MAAM,KAAK,GAAG,sBAAsB,OAAO,kDAAkD,IAAI,SAAS,CAAC;IAC3G,IAAI,OAAO,QAAQ,KAAK,QAAQ;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,IAAI,wCAAwC,KAAK,EAAE,EAAE,CAAC;IAC1I,MAAM,MAAM,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;IACtC,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,IAAI,6BAA6B,QAAQ,8BAA8B,KAAK,EAAE,EAAE,CAAC;IAC5J,IAAI,KAAK,CAAC,MAAM,EAAE,YAAY,CAAC,OAAO,CAAY,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC5F,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,QAAQ,uBAAuB,KAAK,EAAE,EAAE,CAAC;AACrG,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,SAAS,CAAC;AAU7C,4GAA4G;AAC5G,MAAM,UAAU,eAAe,CAAC,MAAwB;IACtD,IAAI,SAAS,IAAI,MAAM;QAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC;IAC7E,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAC/D,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,kCAAkC,EAAE,CAAC;IACzF,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAW,CAAC;IAC7C,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IACnC,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,6BAA6B,IAAI,2BAA2B,EAAE,CAAC;IACvH,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,YAAY,CAAC,mBAAmB,CAAY,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AACpH,CAAC","sourcesContent":["/**\n * The oldest release of each sibling this harness runs, and an installed\n * sibling measured against it.\n *\n * An older sibling is not run. It would answer, and the answer would lack what\n * this release relies on: spec-brief before 0.2.0 has no `waive` hook, so its\n * archive refuses a protected file a signed ruling allows. It is reported with\n * the minimum instead, as a missing sibling is reported (ADR-0002, ADR-0011).\n *\n * package.json says the same minimums to npm as `peerDependencies`; a test\n * holds the two together. So is the oldest Claude Code that runs the hooks,\n * which doctor measures the one on `PATH` against.\n */\n\nimport type { SiblingName } from './config.js';\n\nexport const MINIMUM_VERSIONS: Readonly<Record<SiblingName, string>> = {\n // The plugin `waive` hook, through which spec-brief's archive accepts a\n // protected file that a signed ruling allows.\n 'spec-brief': '0.2.0',\n // An archived brief read as a record, as init configures it, where an older\n // release reads it as retired and what depends on it as a stale premise.\n 'spec-graph': '0.9.0',\n // Scopes read with the spec-core globs the harness reads them with, and an\n // archived brief's assertions withheld rather than run.\n 'spec-guard': '0.12.0',\n};\n\nexport interface Version {\n readonly major: number;\n readonly minor: number;\n readonly patch: number;\n readonly prerelease: boolean;\n}\n\n/** A semver version, or `null`. Build metadata is accepted and ignored, as precedence ignores it. */\nexport function parseVersion(text: string): Version | null {\n const match = /^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$/.exec(text);\n if (match === null) return null;\n return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] !== undefined };\n}\n\n/**\n * Whether `installed` is `minimum` or later by semver precedence. A minimum is\n * a release, never a prerelease, so a prerelease of the minimum comes before\n * it and a prerelease of anything later comes after it.\n */\nexport function meets(installed: Version, minimum: Version): boolean {\n const order = [installed.major - minimum.major, installed.minor - minimum.minor, installed.patch - minimum.patch].find((difference) => difference !== 0) ?? 0;\n return order > 0 || (order === 0 && !installed.prerelease);\n}\n\nexport type VersionCheck =\n | { readonly ok: true; readonly version: string }\n | { readonly ok: false; readonly version: string | null; readonly reason: string };\n\n/**\n * The `version` an installed sibling's package.json declares, against the\n * minimum. A version that cannot be read cannot be shown to meet it, and is\n * refused as an old one is.\n */\nexport function checkVersion(name: SiblingName, declared: unknown): VersionCheck {\n const minimum = MINIMUM_VERSIONS[name];\n const needs = `spec-harness needs ${minimum} or later: npm install --save-dev @descent-vtt/${name}@latest`;\n if (typeof declared !== 'string') return { ok: false, version: null, reason: `the ${name} installed here declares no version; ${needs}` };\n const parsed = parseVersion(declared);\n if (parsed === null) return { ok: false, version: declared, reason: `the ${name} installed here declares \"${declared}\", which is not a version; ${needs}` };\n if (meets(parsed, parseVersion(minimum) as Version)) return { ok: true, version: declared };\n return { ok: false, version: declared, reason: `${name} ${declared} is installed here; ${needs}` };\n}\n\n/**\n * The oldest Claude Code that runs the guard hooks (ADR-0012). Its changelog\n * adds hook `args` in 2.1.139. An older release runs the hook's bare\n * `command`, `node`, which reads the hook's input as a script and fails, and\n * a PreToolUse hook that fails with anything but exit 2 blocks nothing: every\n * write passes unguarded.\n */\nexport const CLAUDE_CODE_MINIMUM = '2.1.139';\n\n/** What `claude --version` printed, or why nothing could be asked. */\nexport type ClaudeCodeAnswer = { readonly output: string } | { readonly missing: string };\n\nexport type ClaudeCodeCheck =\n | { readonly state: 'ok' | 'outdated'; readonly version: string }\n /** Never read as fine: an older release lets every write through. */\n | { readonly state: 'unknown'; readonly reason: string };\n\n/** The release `claude --version` names in its first word, `2.1.235 (Claude Code)`, against the minimum. */\nexport function checkClaudeCode(answer: ClaudeCodeAnswer): ClaudeCodeCheck {\n if ('missing' in answer) return { state: 'unknown', reason: answer.missing };\n const said = answer.output.trim().split('\\n')[0]?.trim() ?? '';\n if (said === '') return { state: 'unknown', reason: 'claude --version printed nothing' };\n const first = said.split(/\\s+/)[0] as string;\n const parsed = parseVersion(first);\n if (parsed === null) return { state: 'unknown', reason: `claude --version printed \"${said}\", which is not a version` };\n return { state: meets(parsed, parseVersion(CLAUDE_CODE_MINIMUM) as Version) ? 'ok' : 'outdated', version: first };\n}\n"]}
1
+ {"version":3,"file":"versions.js","sourceRoot":"","sources":["../src/versions.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The oldest release of each sibling this harness runs, and an installed\n * sibling measured against it.\n *\n * An older sibling is not run. It would answer, and the answer would lack what\n * this release relies on: spec-brief before 0.2.0 has no `waive` hook, so its\n * archive refuses a protected file a signed ruling allows. It is reported with\n * the minimum instead, as a missing sibling is reported (ADR-0002, ADR-0011).\n *\n * package.json says the same minimums to npm as `peerDependencies`; a test\n * holds the two together. So is the oldest Claude Code that runs the hooks,\n * which doctor measures the one on `PATH` against.\n */\n\nimport type { SiblingName } from './config.js';\n\nexport const MINIMUM_VERSIONS: Readonly<Record<SiblingName, string>> = {\n // The plugin `waive` hook, through which spec-brief's archive accepts a\n // protected file that a signed ruling allows.\n 'spec-brief': '0.2.0',\n // An archived brief read as a record, as init configures it, where an older\n // release reads it as retired and what depends on it as a stale premise.\n 'spec-graph': '0.9.0',\n // Scopes read with the spec-core globs the harness reads them with, and an\n // archived brief's assertions withheld rather than run.\n 'spec-guard': '0.12.0',\n};\n\nexport interface Version {\n readonly major: number;\n readonly minor: number;\n readonly patch: number;\n readonly prerelease: boolean;\n}\n\n/** A semver version, or `null`. Build metadata is accepted and ignored, as precedence ignores it. */\nexport function parseVersion(text: string): Version | null {\n const match = /^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$/.exec(text);\n if (match === null) return null;\n return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] !== undefined };\n}\n\n/**\n * Whether `installed` is `minimum` or later by semver precedence. A minimum is\n * a release, never a prerelease, so a prerelease of the minimum comes before\n * it and a prerelease of anything later comes after it.\n */\nexport function meets(installed: Version, minimum: Version): boolean {\n const order = [installed.major - minimum.major, installed.minor - minimum.minor, installed.patch - minimum.patch].find((difference) => difference !== 0) ?? 0;\n return order > 0 || (order === 0 && !installed.prerelease);\n}\n\nexport type VersionCheck =\n | { readonly ok: true; readonly version: string }\n | { readonly ok: false; readonly version: string | null; readonly reason: string };\n\n/**\n * The `version` an installed sibling's package.json declares, against the\n * minimum. A version that cannot be read cannot be shown to meet it, and is\n * refused as an old one is.\n */\nexport function checkVersion(name: SiblingName, declared: unknown): VersionCheck {\n const minimum = MINIMUM_VERSIONS[name];\n const needs = `spec-harness needs ${minimum} or later: npm install --save-dev @descent-vtt/${name}@latest`;\n if (typeof declared !== 'string') return { ok: false, version: null, reason: `the ${name} installed here declares no version; ${needs}` };\n const parsed = parseVersion(declared);\n if (parsed === null) return { ok: false, version: declared, reason: `the ${name} installed here declares \"${declared}\", which is not a version; ${needs}` };\n if (meets(parsed, parseVersion(minimum) as Version)) return { ok: true, version: declared };\n return { ok: false, version: declared, reason: `${name} ${declared} is installed here; ${needs}` };\n}\n\n/**\n * The oldest Claude Code that runs the guard hooks (ADR-0012). Its changelog\n * adds hook `args` in 2.1.139. An older release runs the hook's bare\n * `command`, `node`, which reads the hook's input as a script and fails, and\n * a PreToolUse hook that fails with anything but exit 2 blocks nothing: every\n * write passes unguarded.\n */\nexport const CLAUDE_CODE_MINIMUM = '2.1.139';\n\n/**\n * What `claude --version` printed; the version the package.json of the\n * Claude Code a Windows shim runs declares, which is `file`; or why neither\n * could be had.\n */\nexport type ClaudeCodeAnswer = { readonly output: string } | { readonly declared: string; readonly file: string } | { readonly missing: string };\n\nexport type ClaudeCodeCheck =\n /** `file` is the package.json the version was read from, when a shim was read rather than claude run. */\n | { readonly state: 'ok' | 'outdated'; readonly version: string; readonly file?: string }\n /** Never read as fine: an older release lets every write through. */\n | { readonly state: 'unknown'; readonly reason: string };\n\n/**\n * The release `claude --version` names in its first word, `2.1.235 (Claude\n * Code)`, or the one a package.json declares, against the minimum.\n */\nexport function checkClaudeCode(answer: ClaudeCodeAnswer): ClaudeCodeCheck {\n if ('missing' in answer) return { state: 'unknown', reason: answer.missing };\n if ('declared' in answer) {\n const declared = parseVersion(answer.declared);\n if (declared === null) return { state: 'unknown', reason: `${answer.file} declares \"${answer.declared}\", which is not a version` };\n return { state: meets(declared, parseVersion(CLAUDE_CODE_MINIMUM) as Version) ? 'ok' : 'outdated', version: answer.declared, file: answer.file };\n }\n // split() gives at least one string, so the first line is always there.\n const said = (answer.output.trim().split('\\n')[0] as string).trim();\n if (said === '') return { state: 'unknown', reason: 'claude --version printed nothing' };\n const first = said.split(/\\s+/)[0] as string;\n const parsed = parseVersion(first);\n if (parsed === null) return { state: 'unknown', reason: `claude --version printed \"${said}\", which is not a version` };\n return { state: meets(parsed, parseVersion(CLAUDE_CODE_MINIMUM) as Version) ? 'ok' : 'outdated', version: first };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descent-vtt/spec-harness",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The agent-facing member of the spec-* tools: context for a round, a guard on every write, an audit when it ends, probes that prove a defect before it is filed, and rulings a person signs.",
5
5
  "keywords": [
6
6
  "agents",