sign-off-axi 0.3.5 → 0.3.7

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/README.md CHANGED
@@ -131,6 +131,11 @@ sign-off-axi post --type explainer --title "<question>" --media <video> [--about
131
131
  sign-off-axi report init <folder> write a report page to fill: the answer and a picture, a diagram, steps to tap, the detail folded
132
132
  sign-off-axi post --type report --title "<the answer>" --about "<why it matters>" --view <page.html> --preview <picture>
133
133
  post that page as a report: a card with its picture that opens full screen; text alone is refused
134
+ sign-off-axi post --type signin --service "<name>" --link <sign-in URL> --unlocks "<what>" --waits "<who>" [--expires 15m] [--key <key>]
135
+ ask the user to sign in: one card on top with an Open button and the time left
136
+ sign-off-axi post --type signin --service "<name>" --start '<command as a JSON list>' --unlocks "<what>" --waits "<who>"
137
+ the same card, with a new link made at each tap: Open runs the command on this computer
138
+ sign-off-axi signin done --key <key> the sign-in is done: close its card
134
139
  sign-off-axi init [dir] [--agent auto|<name>] write the repo setup under <dir>/.sign-off/ (config, board, mocks/ and evidence/ folders)
135
140
  and install the skill for each chosen coding agent (auto: each one on PATH)
136
141
  sign-off-axi doctor [dir] check that setup and each agent's skill; every failure names its fix
@@ -151,11 +156,12 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
151
156
  - `post --type design` asks for a pick between up to 6 mock options, each with an optional `--mock <value>=<path>` file (`.html`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`, 5 MB or less) shown as a tall phone frame above its button (the recommended one first; on a phone the frames swipe sideways, the next one peeking in, and a tap opens one full size), plus a `--tradeoff` line and a `--recommend` badge. Mock paths are relative to the board file's folder and never escape it. One `.html` page can hold every variant: `--mock a=mocks/<key>/index.html?variant=a` loads the page with `?variant=a`. An `.html` mock or view loads the css, scripts, images and fonts beside it by relative name (`style.css`, `img/dot.png`), from its own folder only, never anything above it. The board adds a small pointing script to each `.html` mock: once the board turns it on for that frame, a tap on a part of the mock is kept from the mock and named back to the board as a path to that element, its tag and its words; it passes only text and gives the mock no reach into the board. With **Comment on the mocks** on, a tap opens a comment on that element: one per element, listed under the mocks with Edit and Delete (words typed but not yet saved stay with their element while the page is open, so tapping another element and back keeps them; a reload drops them), kept on the board and shown, until it is sent, only on the exact mock file it was made on. The comments go to the agent with the next answer on that card (a pick, Other answer or **Another round**, which may then have no note), each with its element, in `wait` and `answers` (`comments`); once sent they are listed as sent and can no longer change. An option with no mock, or whose file is gone, says "No mock for this option". `post --type decision` (the default) is exactly `raise`, and `raise` stays as its alias.
152
157
  - `post --type evidence` posts the next numbered update (`update-1`, `update-2`, ...) with 1 to 12 screenshots or recordings (`.png`, `.jpg`, `.jpeg`, `.webp`, `.mp4`, `.webm`, up to 50 MB each), a short note and an optional commit or PR link. The files are copied into `evidence/<number>/` next to the board, so the board is the store: no hosted service. The page shows the updates newest first; each file is served only while its size and SHA-256 still match what was posted, and recordings play inline on a phone. The person gives a verdict, **Pass**, **Fail** or **Needs work**, with an optional note, read back with `answers` or `wait --key update-<number>`.
153
158
  - `post --type diagram` and `post --type interactive` show a question with a picture: `--view <path>` names one board-relative file, an image (`.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) drawn at card width for a diagram, or one self-contained `.html` page that runs in a sandboxed frame for interactive. Every mock and view is served under a Content-Security-Policy with no `fetch`, no remote files and no access to the dashboard or its tab. They answer like a decision.
154
- - `post --type report` posts an explainer page the user reads, with no answer: something they asked to have explained, or a concept, design or trade-off they must understand. `sign-off-axi report init <folder>` writes the page to fill, one self-contained `.html` file, phone first, light and dark: the answer in one plain sentence with a picture, then a diagram, then steps the reader taps through, then the detail folded. Reports and explainers lists each one as a card with its picture; a tap opens the page over the whole board, on a phone and a desk alike, with **‹ Reports** (or Escape) to go back. A report of text only is refused with the command that makes the page; one stored before reports had pages still loads, folded under **Notes**. Proof that something works is evidence, and a merge or a status note is neither.
159
+ - `post --type report` posts an explainer page the user reads, with no answer: something they asked to have explained, or a concept, design or trade-off they must understand. `sign-off-axi report init <folder>` writes the page to fill, one self-contained `.html` file, phone first, light and dark: the answer in one plain sentence with a picture and an as-of line naming the date the facts were true, then a diagram, then steps the reader taps through, then the detail folded. Reports and explainers lists each one as a card with its picture; a tap opens the page over the whole board, on a phone and a desk alike, with **‹ Reports** (or Escape) to go back. A report of text only is refused with the command that makes the page; one stored before reports had pages still loads, folded under **Notes**. Proof that something works is evidence, and a merge or a status note is neither.
155
160
  - `post --type explainer` shows a question with a narrated video the agent made itself (stills plus speech from a free local engine such as piper or espeak-ng, joined with ffmpeg; the skill has the recipe). `--media` names one `.mp4` or `.webm`, copied into `evidence/<number>/` and served hash-checked like evidence media; it plays on the card with its own controls, at phone width too. `--about` is a short summary and each `--chapter m:ss=<title>` is a button that plays the video from there. sign-off itself never makes the video: it calls no speech engine, no ffmpeg and no network service.
161
+ - `post --type signin` asks the person to sign in, for example with the link `npm login` prints. The card sits on top of Waiting on you, because someone waits on it now: the service, one big **Open** for the link (a new tab), what it unlocks, who waits, and the time left until `--expires` (an ISO date-time, or `90s`, `15m`, `2h`, `1d` from now), counting down on the open page. When the time is past, the card says "Link expired. Ask for a new link." and **Open** is off. Post again with the same `--key` and a new `--link` or `--expires` to put a new link on the same card; it waits again, also after it was done. The poster runs `signin done --key <key>` once it sees the sign-in worked: the card leaves Waiting on you and shows as **Signed in** under Answered; the open page picks that up within 20 seconds, with no reload. For a card posted with a link, the board runs no check of its own. The link must be `http(s)` with no user name or password in it, and no output prints it, because it can hold a one-time code. When links expire fast (an `npm login --auth-type=web` link lasts about 5 minutes), post `--start '["npm", "login", "--auth-type=web"]'` in place of `--link` and `--expires`: each tap on **Open** runs that command on the computer that serves the board (as a list, with no shell, in the board file's folder), opens a new tab at the first `https` link it prints on stdout (on stderr only when stdout has none within 2 seconds, since tools such as npm print other links there first), and keeps it running so the sign-in can finish; a second tap while it runs opens the same link. Its exit 0 closes the card as done by itself; a failure, or no link within 20 seconds, shows on the card in plain words and **Open** tries again. It is stopped after 10 minutes, when the card closes, or when the board stops. The page only learns that a tap makes a new link, never the command, and the link is kept in memory only while the command runs: never stored, printed or logged.
156
162
  - The person can also tap **Another round** inside **Other answer** (with the typed note), which wakes a waiting agent the same way an answer does; the agent reads it back in `answers --json` under `rounds`, with any comments on mock elements it carried.
157
163
  - **Dismiss** (the × on an item in a list, **Dismiss** under Needs you first and beside **Skip for now** in Triage, or **Clear all done** on Answered) hides items from the board, Today included; **Undo** in the toast brings them back, and so does **Restore** under **Dismissed (N)** at the end of Needs you first and Triage. `list` counts a dismissed item as neither waiting nor answered, as the board does (`list --full` says `dismissed` for an unanswered one and still its answer for an answered one), and `answers` does not count it as waiting; the agent is not told, so an ask it waits on keeps waiting, and `answers` still returns a dismissed answer. The storage is in [docs/file-formats.md](docs/file-formats.md).
158
- - The person can comment on any item (**Send as a comment**, also inside **Other answer**: each card has that one place to type) and queue a free-text prompt for the agent (**Ask the agent**). `poll` blocks until one arrives and prints every new one once (`--json` prints `{"messages":[{"id","kind","key"?,"text","at"}]}`); the page then shows it as read by the agent. Typed notes that are not sent yet stay in the browser across reloads; everything sent is saved next to the board and survives a restart.
164
+ - The person can comment on any item (**Send as a comment**, also inside **Other answer**: every card but a sign-in has that one place to type) and queue a free-text prompt for the agent (**Ask the agent**). `poll` blocks until one arrives and prints every new one once (`--json` prints `{"messages":[{"id","kind","key"?,"text","at"}]}`); the page then shows it as read by the agent. Typed notes that are not sent yet stay in the browser across reloads; everything sent is saved next to the board and survives a restart.
159
165
  - The board runs on your own machine only: no hosted service, no recurring cost. It listens on `127.0.0.1` unless `--bind <address>` (or `bind:` in the `config.yaml` that `init` writes) names one private address of this machine, such as its VPN address, so a phone or tablet on your own network can open it. A `hosts:` line in that config names private DNS names that lead to the bind address, such as its mesh VPN name (commas between them), so the board also opens by name; `doctor` checks each one resolves to the bind address. All interfaces and public addresses are refused. Beyond `127.0.0.1` the board makes an access token once (`decisions.token`, mode 600, next to the board), `open` prints it in the link, and every request without it is refused; the link sets a cookie, so the page and its media work after the first load. Delete the token file to shut old links out at once, then `stop` and `open` for a new one.
160
166
  - `export` writes one self-contained HTML file (`decisions.export.html` by default): every item, answer, note and another-round request with its local mocks and media inlined; comments and queued prompts are left out. `--out` must end in `.html`, so a wrong path never overwrites the board. It opens from disk with no server, is read-only, and makes no network request. A mock page is inlined on its own, so the css and images beside it show only on the running board.
161
167
  - With no `--option` a question is Yes/No; give 2 to 6 `--option` flags for a pick-one question (`--option friday=Friday --option next-week="Next week"`). `--recommend` and `--default` name one of its values. Listing options needs `--recommend` (on `raise`, `post` and `--from` files): the board shows that option first and marks it; a plain Yes/No may leave it out. Each answer is one tap, with an Undo for a few seconds after it (an Undo is saved as an answer change). Typing is last: every question with options, a design's Picks included, has a text box folded behind **Other answer** (none when an option is valued `text`); the words are saved as the answer `text`, with the same Undo.
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, reso
10
10
  import { parseArgs } from 'node:util';
11
11
  import { encode } from '@toon-format/toon';
12
12
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
- import { CHAPTERS_MAX, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
13
+ import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
14
14
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
15
15
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
16
16
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
@@ -26,8 +26,8 @@ const AGENTS = {
26
26
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
27
27
  };
28
28
  const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
29
- commands[16]:
30
- (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, doctor, validate, help
29
+ commands[17]:
30
+ (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, doctor, validate, help
31
31
  board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
32
32
  flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
33
33
  examples:
@@ -39,6 +39,8 @@ examples:
39
39
  sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start
40
40
  sign-off-axi report init .sign-off/mocks/sign-in-flow
41
41
  sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
42
+ sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
43
+ sign-off-axi signin done --key npm-login
42
44
  sign-off-axi init
43
45
  sign-off-axi doctor
44
46
  sign-off-axi validate ask.json
@@ -167,6 +169,8 @@ examples:
167
169
  sign-off-axi post [board.json] --kind <kind> --from <message.json|-> [--filer <id>]
168
170
  sign-off-axi post [board.json] --type explainer --title "<question>" --media <video> [--about <summary>] [--chapter <m:ss>=<title>]... [--key <key>] [--option <value>=<label>]... [--recommend <value>]
169
171
  sign-off-axi post [board.json] --type report --title "<the answer>" --about "<why it matters>" --view <page.html> --preview <picture> [--key <key>] [--link <commit or URL>] [--filer <id>]
172
+ sign-off-axi post [board.json] --type signin --service "<name>" --link <sign-in URL> --unlocks "<what it unlocks>" --waits "<who waits>" [--expires <ISO time or 15m>] [--key <key>] [--filer <id>]
173
+ sign-off-axi post [board.json] --type signin --service "<name>" --start '<command as a JSON list>' --unlocks "<what it unlocks>" --waits "<who waits>" [--key <key>] [--filer <id>]
170
174
  Posts an item for the user. --type decision (the default) is a yes/no or pick-one question, exactly like raise.
171
175
  --kind posts one contract message (${CONTRACT}), read from a file or from stdin with --from -: it must be that kind and pass every rule,
172
176
  else it is refused with one fix line and nothing is written. A key already on the board takes only the same message again; anything else exits 2.
@@ -200,6 +204,14 @@ and played on the card. --about is its short summary; up to ${CHAPTERS_MAX} --ch
200
204
  --view is the page and --preview its picture on the card, beside it, both from the board file's folder; \`sign-off-axi report init <folder>\` makes them.
201
205
  The page must be filled (no FILL: line left), with a viewport tag, dark mode, a picture, something to tap, its detail folded, nothing from the web, and no block over about five lines on a phone.
202
206
  Merges, PR outcomes, receipts and status notes are not reports: post proof of a change as evidence.
207
+ --type signin asks the user to sign in, at the top of what waits: the service, one Open button for the http(s) --link, what it unlocks,
208
+ who waits, and the time left until --expires (an ISO date-time, or 90s, 15m, 2h, 1d from now). After that time the card says the link expired.
209
+ No output prints the link. Post again with the same --key and a new --link or --expires to put a new link on the same card; it waits again.
210
+ When the sign-in is done, run \`sign-off-axi signin done [board.json] --key <key>\`: the card leaves the open list and shows as done.
211
+ For a link that expires fast, give --start, the command that prints a new sign-in link, as a JSON list such as '["npm", "login", "--auth-type=web"]'.
212
+ Each tap on Open then runs it on the computer that serves the board (no shell, in the board file's folder) and opens the first https link it prints on stdout
213
+ (on stderr when stdout has none within 2 seconds), so the card never shows an expired link. A tap while it runs opens the same link again. Its exit 0 closes the card as done; a failure, or no link
214
+ within 20 seconds, shows on the card and Open can try again. It is stopped after 10 minutes, or when the card closes. The link is never stored or printed.
203
215
  examples:
204
216
  sign-off-axi post --type design --title "Pick the sign-in layout" --option a="A: single column" --option b="B: split screen" --mock a=mocks/a.html --mock b=mocks/b.html --tradeoff "B scans fastest; A is simplest." --recommend b
205
217
  sign-off-axi post .sign-off/decisions.json --type design --key sign-in --title "Pick the sign-in layout" --option a=A --option b=B --option c=C --mock a=mocks/sign-in/index.html?variant=a --mock b=mocks/sign-in/index.html?variant=b --mock c=mocks/sign-in/index.html?variant=c --tradeoff "..." --recommend b
@@ -212,6 +224,14 @@ examples:
212
224
  sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media phone.png --media flow.mp4 --link https://github.com/acme/app/pull/12
213
225
  sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start --chapter 0:12="Step 2"
214
226
  sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
227
+ sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
228
+ sign-off-axi post --type signin --key npm-login --service npm --start '["npm", "login", "--auth-type=web"]' --unlocks "Publish the release" --waits "Main"
229
+ `,
230
+ signin: `usage: sign-off-axi signin done [board.json] --key <key>
231
+ Closes a sign-in card that post --type signin made: the sign-in is done, so the card leaves the open list and shows as done.
232
+ Run it when you see the sign-in worked. A card posted with --start also closes by itself when its command ends with exit 0. wait --key <key> also returns once it is done.
233
+ examples:
234
+ sign-off-axi signin done --key npm-login
215
235
  `,
216
236
  report: `usage: sign-off-axi report init <folder>
217
237
  Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
@@ -422,7 +442,7 @@ function filerOf(values, session = false) {
422
442
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
423
443
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
424
444
  /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
425
- const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply);
445
+ const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply || d.type === 'signin');
426
446
  /** The board's decisions with their latest answer; a missing board file is an empty board.
427
447
  * A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
428
448
  function board(file, say = warn) {
@@ -455,7 +475,7 @@ function list(args) {
455
475
  const { file, values, on } = parse('list', args, TIERS);
456
476
  const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
457
477
  if (values.json)
458
- return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => (dismissed ? { ...d, dismissed } : d)) });
478
+ return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => ({ ...d, ...(d.type === 'signin' && { link: undefined }), ...(dismissed && { dismissed }) })) });
459
479
  const help = [
460
480
  ...(waiting.length ? [`Run \`sign-off-axi open${on}\` to show the board to the user`] : []),
461
481
  ...(answered ? [`Run \`sign-off-axi answers${on}\` to read the ${answered} saved answers`] : []),
@@ -709,10 +729,11 @@ const mockFile = (path) => path.split('?')[0];
709
729
  /** Posts an item. `raise` is the alias for `post --type decision`: same flags as before, same output. */
710
730
  function post(args, cmd = 'post') {
711
731
  const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
712
- media: { type: 'string', multiple: true }, link: { type: 'string' }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true } } : {};
732
+ media: { type: 'string', multiple: true }, link: { type: 'string' }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
733
+ ...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
713
734
  const { file, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
714
735
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: f === 'option' }])) });
715
- const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter'].filter((f) => values[f] !== undefined);
736
+ const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
716
737
  if (values.kind !== undefined) {
717
738
  const odd = [...given, ...(values.replace ? ['replace'] : [])][0];
718
739
  if (odd)
@@ -727,6 +748,11 @@ function post(args, cmd = 'post') {
727
748
  const type = values.type ?? 'decision';
728
749
  if (type === 'report')
729
750
  return postReport(file, on, values);
751
+ if (type === 'signin')
752
+ return postSignin(file, on, values, filer);
753
+ const odd = SIGNIN_FIELDS.find((f) => values[f] !== undefined);
754
+ if (odd)
755
+ throw fail(`--${odd} needs --type signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
730
756
  if (values.preview !== undefined)
731
757
  throw fail('--preview needs --type report', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
732
758
  if (values.from === undefined && values.title === undefined) {
@@ -734,7 +760,7 @@ function post(args, cmd = 'post') {
734
760
  : type === 'evidence' ? 'sign-off-axi post --type evidence --title "<what this shows>" --about "<short note>" --media <file>' : `sign-off-axi ${cmd} --title "<question>" [--recommend <value>]`);
735
761
  }
736
762
  if (!['decision', 'design', 'evidence', 'diagram', 'interactive', 'explainer'].includes(type))
737
- throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer or report`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
763
+ throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer, report or signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
738
764
  if (type === 'evidence' || type === 'explainer')
739
765
  return postMedia(file, on, values, type, filer);
740
766
  if (values.media !== undefined || values.link !== undefined || values.chapter !== undefined)
@@ -794,6 +820,86 @@ function post(args, cmd = 'post') {
794
820
  });
795
821
  return storeIncoming(file, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
796
822
  }
823
+ const SIGNIN_FIELDS = ['service', 'unlocks', 'waits', 'expires', 'start'];
824
+ const SIGNIN_EXAMPLE = 'sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m';
825
+ const SIGNIN_START_EXAMPLE = 'sign-off-axi post --type signin --key npm-login --service npm --start \'["npm", "login", "--auth-type=web"]\' --unlocks "Publish the release" --waits "Main"';
826
+ /** --expires: an ISO date-time, or a time from now such as 90s, 15m, 2h or 1d; refused when it is already past. */
827
+ function expiresAt(value) {
828
+ const span = /^(\d{1,6})([smhd])$/.exec(value), at = span ? Date.now() + Number(span[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[span[2]]
829
+ : /^\d{4}-\d{2}-\d{2}T/.test(value) ? Date.parse(value) : NaN;
830
+ if (!(at > Date.now()))
831
+ throw fail(`--expires ${value} must be a time still to come: an ISO date-time such as 2026-10-13T07:19:00Z, or a time from now such as 15m`, 'VALIDATION_ERROR', SIGNIN_EXAMPLE);
832
+ return new Date(at).toISOString().replace(/\.\d+Z$/, 'Z');
833
+ }
834
+ /** post --type signin: one card with a link to open. The same key again with another link or time swaps them on that card, which waits again.
835
+ * The link can hold a one-time code, so no output prints it. */
836
+ function postSignin(file, on, values, filer) {
837
+ const odd = ['title', 'about', 'detail', 'option', 'recommend', 'default', 'close', 'tradeoff', 'mock', 'media', 'view', 'preview', 'chapter'].find((f) => values[f] !== undefined);
838
+ if (odd)
839
+ throw fail(`--${odd} does not apply to --type signin`, 'VALIDATION_ERROR', SIGNIN_EXAMPLE);
840
+ const missing = ['service', ...(values.start === undefined ? ['link'] : []), 'unlocks', 'waits'].filter((f) => values[f] === undefined);
841
+ if (missing.length)
842
+ throw fail(`--type signin needs ${missing.map((f) => `--${f}`).join(', ')}${values.link === undefined ? ' (or --start in place of --link)' : ''}`, 'VALIDATION_ERROR', SIGNIN_EXAMPLE);
843
+ const { service, link, unlocks, waits: who, key = `signin-${slug(service)}` } = values;
844
+ let start;
845
+ if (values.start !== undefined) {
846
+ try {
847
+ start = JSON.parse(values.start);
848
+ }
849
+ catch {
850
+ start = undefined;
851
+ }
852
+ if (!Array.isArray(start))
853
+ throw fail('--start must be the command as a JSON list, such as \'["npm", "login", "--auth-type=web"]\'', 'VALIDATION_ERROR', SIGNIN_START_EXAMPLE);
854
+ if (values.link !== undefined || values.expires !== undefined)
855
+ throw fail(`--${values.link !== undefined ? 'link' : 'expires'} does not apply with --start: each tap makes a new link`, 'VALIDATION_ERROR', SIGNIN_START_EXAMPLE);
856
+ }
857
+ const expires = values.expires === undefined ? undefined : expiresAt(values.expires);
858
+ const { incoming: [d], raw: [item] } = buildIncoming({ title: service, key }, { type: 'signin', link, unlocks, waits: who, ...(expires && { expires }), ...(start && { start }) }, filer, file);
859
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
860
+ return locked(`${file}.lock`, () => {
861
+ const items = existsSync(file) ? readItems(file).items : [], at = items.findIndex((x) => x?.key === d.key), had = items[at];
862
+ if (had && had.type !== 'signin')
863
+ throw fail(`key ${d.key} already holds a different question on ${tilde(file)}`, 'CONFLICT', 'Post the sign-in under a new --key');
864
+ const same = had && canon({ ...had, filed: undefined, filer: undefined }) === canon({ ...item, filed: undefined, filer: undefined });
865
+ const done = readAnswers(file)[d.key];
866
+ let status = same ? (done ? 'already done' : 'already waiting') : had ? 'new link' : 'posted';
867
+ if (!same) {
868
+ fits(d.title, d.key);
869
+ const next = { ...item, filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
870
+ if (had)
871
+ items[at] = next;
872
+ else
873
+ items.push(next);
874
+ writeItems(file, items);
875
+ // A new link is a new sign-in: the card waits again, also when it was done or dismissed.
876
+ appendAnswer(file, d, CLEARED);
877
+ if (had)
878
+ appendDismissed(file, [d.key], false);
879
+ }
880
+ return toon({ board: tilde(file), posted: [{ key: d.key, status, service: d.title, ...(expires && { expires }) }] }, [
881
+ ...(status === 'already done' ? [] : [`Run \`sign-off-axi open${on}\` so the user can sign in`]),
882
+ `Run \`sign-off-axi signin done${on} --key ${d.key}\` when the sign-in is done; the card then closes`,
883
+ ...(start ? [] : [`Run \`sign-off-axi post${on} --type signin --key ${d.key} ...\` again with a new --link and --expires when this link expires`])
884
+ ]);
885
+ });
886
+ }
887
+ /** signin done [board.json] --key <key>: the sign-in is done, so its card leaves the open list and shows as done. */
888
+ function signin(args) {
889
+ const [sub, ...rest] = args;
890
+ if (sub !== 'done')
891
+ throw fail(`unknown signin command ${sub ?? '(none)'}`, 'VALIDATION_ERROR', 'sign-off-axi signin done [board.json] --key <key>');
892
+ const { file, values, on } = parse('signin done', rest, { key: { type: 'string' } });
893
+ if (values.key === undefined)
894
+ throw fail('signin done needs --key <key>', 'VALIDATION_ERROR', 'sign-off-axi signin done [board.json] --key <key>');
895
+ return locked(`${file}.lock`, () => {
896
+ const d = board(file).find((x) => x.key === values.key);
897
+ if (d?.type !== 'signin')
898
+ throw fail(`no sign-in with key ${values.key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on}\` to see the waiting keys`);
899
+ const saved = appendAnswer(file, d, SIGNED_IN);
900
+ return toon({ board: tilde(file), closed: [{ key: d.key, status: saved ? 'done' : 'already done', service: d.title }] }, [`Run \`sign-off-axi list${on}\` to see what still waits on the user`]);
901
+ });
902
+ }
797
903
  /** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
798
904
  * With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
799
905
  function replace(file, on, from, filer) {
@@ -812,7 +918,7 @@ function replace(file, on, from, filer) {
812
918
  const feed = fed.items.map((i) => ({ filed: first.get(i?.key)?.filed ?? now, ...i, ...(filer && { filer }) }));
813
919
  // An answer belongs to its question: an answered key may not come back asking something else.
814
920
  // Evidence is answered for the media and run it shows, so those are part of its question too, as a gate's run and a finding's proposal are.
815
- const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply, d.run, d.finding, d.evidence, d.media]);
921
+ const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply, d.run, d.finding, d.evidence, d.media, d.type === 'signin' ? [d.link, d.start] : '']);
816
922
  // A key whose answer outlived it may not come back: the board and the answers are read here, under the lock the feed writes with.
817
923
  const saved = readAnswers(file);
818
924
  for (const i of fed.items)
@@ -1710,6 +1816,7 @@ async function serve(args) {
1710
1816
  if (!server.listening)
1711
1817
  return;
1712
1818
  await sweep('periodic');
1819
+ reap();
1713
1820
  if (server.listening)
1714
1821
  setTimeout(repeat, 5_000).unref();
1715
1822
  };
@@ -1796,6 +1903,7 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
1796
1903
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
1797
1904
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
1798
1905
  const state = () => {
1906
+ reap();
1799
1907
  // The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
1800
1908
  const answers = Object.fromEntries(Object.entries(readAnswers(file, warn)).map(([k, { comments: _, ...a }]) => [k, a])), taken = seenBy(file);
1801
1909
  const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
@@ -1803,7 +1911,8 @@ const state = () => {
1803
1911
  file,
1804
1912
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
1805
1913
  // Who filed an item stays in the file: the page only learns that its filer took the answer.
1806
- decisions: loadDecisions(file).map(({ filer: _, ...d }) => ({ ...d, evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
1914
+ // A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
1915
+ decisions: loadDecisions(file).map(({ filer: _, start, ...d }) => ({ ...d, ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
1807
1916
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
1808
1917
  readonly: readOnly(),
1809
1918
  answers_only: answersOnly(),
@@ -1963,12 +2072,12 @@ async function route(req) {
1963
2072
  if (req.method === 'GET' && req.url === '/api/decisions') {
1964
2073
  return [200, state()];
1965
2074
  }
1966
- if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss'].includes(req.url ?? ''))
2075
+ if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin'].includes(req.url ?? ''))
1967
2076
  return [404, { error: 'Not found' }];
1968
2077
  if (readOnly())
1969
2078
  return [403, { error: 'This board is read-only' }];
1970
2079
  // Dismiss only hides an item on this page and sends nothing to the feeding program, so an answers-only board takes it too.
1971
- if (!['/api/answers', '/api/dismiss'].includes(req.url) && answersOnly())
2080
+ if (!['/api/answers', '/api/dismiss', '/api/signin'].includes(req.url) && answersOnly())
1972
2081
  return [403, { error: 'This board takes answers only' }];
1973
2082
  if (!req.headers['content-type']?.startsWith('application/json'))
1974
2083
  return [415, { error: 'Send JSON' }];
@@ -2016,6 +2125,21 @@ async function route(req) {
2016
2125
  appendAnnotation(file, { id: same?.id, key, mock, hash, ref, tag, words, text: text.trim() });
2017
2126
  return [200, state()];
2018
2127
  }
2128
+ if (req.url === '/api/signin') {
2129
+ // Only the key comes from the page: the command is the one the poster stored on this host.
2130
+ const { key } = JSON.parse(body) ?? {}, d = board(file).find((x) => x.key === key);
2131
+ if (!d?.start)
2132
+ return [404, { error: 'This card has no sign-in to start' }];
2133
+ if (d.answer)
2134
+ return [409, { error: 'This sign-in is already done' }];
2135
+ try {
2136
+ return [200, { open: await signinLink(d), state: state() }];
2137
+ }
2138
+ catch (e) {
2139
+ // A run that ended with exit 0 before it printed a link was already signed in: the card is done, with no link to open.
2140
+ return board(file).find((x) => x.key === key)?.answer ? [200, { state: state() }] : [502, { error: e.message, state: state() }];
2141
+ }
2142
+ }
2019
2143
  if (req.url === '/api/dismiss') {
2020
2144
  const { keys, dismissed } = JSON.parse(body) ?? {};
2021
2145
  if (!Array.isArray(keys) || !keys.length || keys.length > 200 || typeof dismissed !== 'boolean')
@@ -2078,6 +2202,96 @@ function answered(a, d) {
2078
2202
  child.stdin.on('error', () => { });
2079
2203
  child.stdin.end(`${JSON.stringify({ event: 'answer', ...kindOf(file)(a.key), ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
2080
2204
  }
2205
+ const runs = new Map(), failures = new Map();
2206
+ const SIGNIN_WAIT = 20_000, SIGNIN_LIFE = 600_000;
2207
+ const signinState = (key) => (runs.get(key)?.url ? 'live' : runs.has(key) ? 'starting' : failures.has(key) ? `failed: ${failures.get(key)}` : 'none');
2208
+ /** Stops a run and its child processes (it leads its own process group): SIGTERM first, SIGKILL if it stays. */
2209
+ function stopRun(key, why) {
2210
+ const run = runs.get(key);
2211
+ if (!run)
2212
+ return;
2213
+ run.why ??= why;
2214
+ const signal = (sig) => { try {
2215
+ process.kill(-run.child.pid, sig);
2216
+ }
2217
+ catch {
2218
+ run.child.kill(sig);
2219
+ } };
2220
+ signal('SIGTERM');
2221
+ setTimeout(() => { if (runs.get(key) === run)
2222
+ signal('SIGKILL'); }, 2_000).unref();
2223
+ }
2224
+ /** Stops each run whose card closed: done, gone from the board, no longer a start-command sign-in, or swapped for another command. */
2225
+ function reap() {
2226
+ if (!runs.size)
2227
+ return;
2228
+ const items = new Map(board(file, () => { }).map((d) => [d.key, d]));
2229
+ for (const key of runs.keys()) {
2230
+ const d = items.get(key), run = runs.get(key);
2231
+ if (!d?.start || d.answer)
2232
+ stopRun(key, 'The card closed.');
2233
+ else if (JSON.stringify(d.start) !== JSON.stringify(run.argv))
2234
+ stopRun(key, 'A new sign-in was posted for this card.');
2235
+ }
2236
+ }
2237
+ /** The live link for a tap on Open: a run still going gives its link again; else the start command runs with no shell in the board's folder,
2238
+ * and the first https link it prints on stdout (else on stderr) is the link. Its exit 0 closes the card as done. The link is never logged or stored. */
2239
+ function signinLink(d) {
2240
+ const had = runs.get(d.key);
2241
+ if (had)
2242
+ return had.link;
2243
+ failures.delete(d.key);
2244
+ const [cmd, ...rest] = d.start, child = spawn(cmd, rest, { cwd: dirname(file), stdio: ['ignore', 'pipe', 'pipe'], detached: true });
2245
+ let found, lost, aside;
2246
+ const run = { child, argv: [cmd, ...rest], link: new Promise((ok, no) => { found = ok; lost = (why) => no(new Error(why)); }) };
2247
+ run.link.catch(() => { });
2248
+ runs.set(d.key, run);
2249
+ const take = (url) => { if (!run.url)
2250
+ found((run.url = url)); };
2251
+ // The link is the first https link on stdout: tools such as npm print notices with other links on stderr first.
2252
+ // ponytail: a tool that prints its link only on stderr (gh) is read from there after 2 s of nothing on stdout; add a link pattern per card if a tool needs one.
2253
+ const reader = (onOut) => {
2254
+ let seen = '';
2255
+ return (b) => {
2256
+ if (run.url)
2257
+ return;
2258
+ seen = (seen + b).slice(-8000);
2259
+ const url = /https:\/\/[^\s"'<>`]+/.exec(seen)?.[0];
2260
+ if (url && onOut)
2261
+ take(url);
2262
+ else if (url)
2263
+ aside ??= setTimeout(() => take(url), 2000);
2264
+ };
2265
+ };
2266
+ child.stdout.on('data', reader(true));
2267
+ child.stderr.on('data', reader(false));
2268
+ const wait = setTimeout(() => run.url || stopRun(d.key, 'It gave no sign-in link within 20 seconds.'), SIGNIN_WAIT);
2269
+ const life = setTimeout(() => stopRun(d.key, 'It was not done within 10 minutes, so it was stopped.'), SIGNIN_LIFE);
2270
+ const end = (why) => {
2271
+ clearTimeout(aside);
2272
+ clearTimeout(wait);
2273
+ clearTimeout(life);
2274
+ if (runs.get(d.key) !== run)
2275
+ return;
2276
+ runs.delete(d.key);
2277
+ if (why)
2278
+ failures.set(d.key, why);
2279
+ lost(why ?? 'The sign-in is done.');
2280
+ };
2281
+ child.on('error', (e) => end(e.code === 'ENOENT' ? `${basename(cmd)} is not on this computer.` : `It did not start (${e.code ?? e.message}).`));
2282
+ child.on('exit', (code) => {
2283
+ if (code === 0) {
2284
+ const now = board(file).find((x) => x.key === d.key);
2285
+ if (now?.start && !now.answer && JSON.stringify(now.start) === JSON.stringify(run.argv))
2286
+ answered(appendAnswer(file, now, SIGNED_IN), now);
2287
+ return end();
2288
+ }
2289
+ end(run.why ?? (code === null ? "It was stopped." : `It stopped with exit code ${code}.`));
2290
+ });
2291
+ return run.link;
2292
+ }
2293
+ process.on('exit', () => { for (const key of runs.keys())
2294
+ stopRun(key, 'The board stopped.'); });
2081
2295
  const server = createServer(async (req, res) => {
2082
2296
  const { port: bound } = server.address();
2083
2297
  // Only loopback names, the bound address and the config's hosts: names: blocks DNS rebinding from a web page.
@@ -2132,7 +2346,7 @@ await runAxiCli({
2132
2346
  topLevelHelp: TOP_HELP,
2133
2347
  getCommandHelp: (cmd) => HELP[cmd],
2134
2348
  home: axi('list', list),
2135
- commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), doctor: axi('doctor', doctor), validate, help: ([name, ...rest]) => {
2349
+ commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), doctor: axi('doctor', doctor), validate, help: ([name, ...rest]) => {
2136
2350
  if (rest.length || (name && !HELP[name]))
2137
2351
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
2138
2352
  return name ? HELP[name] : TOP_HELP;
package/dist/store.js CHANGED
@@ -13,6 +13,8 @@ export function unreadable(code) {
13
13
  : `cannot be read (${code ?? 'unknown error'})`;
14
14
  }
15
15
  const YES_NO = [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }];
16
+ /** The answer `signin done` saves: the sign-in is done and its card leaves the open list. */
17
+ export const SIGNED_IN = { value: 'done', label: 'Signed in' };
16
18
  /** The answer a `reply: true` item saves for a short text reply; the text is its note. */
17
19
  export const REPLY = { value: 'text', label: 'Reply' };
18
20
  const VERDICTS = [{ value: 'pass', label: 'Pass' }, { value: 'fail', label: 'Fail' }, { value: 'needs-work', label: 'Needs work' }];
@@ -120,8 +122,35 @@ export function loadDecisions(path, board) {
120
122
  throw bad('decided', 'must be text of 200 characters or fewer');
121
123
  if (d.type === 'evidence' && !(Number.isInteger(d.number) && d.number >= 1))
122
124
  throw bad('number', 'must be a whole number from 1');
123
- if (d.link !== undefined && !(isText(d.link) && /^(https?:\/\/\S{1,500}|[a-f0-9]{7,40})$/u.test(d.link)))
125
+ const signin = d.type === 'signin';
126
+ if (d.link !== undefined && !(isText(d.link) && /^(https?:\/\/\S{1,500}|[a-f0-9]{7,40})$/u.test(d.link)) && !signin)
124
127
  throw bad('link', 'must be an http(s) link or a commit hash');
128
+ // A sign-in is a link to open, what it unlocks and who waits; its answer is the `signin done` its poster saves, so the user gets no buttons.
129
+ if (signin) {
130
+ // A start command makes a new link at each tap, so it needs no fixed link and never expires.
131
+ const start = d.start !== undefined;
132
+ if (start && !(Array.isArray(d.start) && d.start.length >= 1 && d.start.length <= 64 && d.start.every((a) => isText(a) && a.length <= 4096 && !a.includes('\0')) && d.start[0].trim())) {
133
+ throw bad('start', 'must be a command and its arguments as a list of 1 to 64 texts, such as ["npm", "login", "--auth-type=web"]');
134
+ }
135
+ if (start && d.expires !== undefined)
136
+ throw bad('expires', 'must be left out with start: each tap makes a new link');
137
+ let url;
138
+ try {
139
+ url = new URL(d.link);
140
+ }
141
+ catch { }
142
+ if ((!start || d.link !== undefined) && (!(isText(d.link) && /^https?:\/\/\S{1,2000}$/u.test(d.link)) || !url || url.username || url.password)) {
143
+ throw bad('link', `must be an http(s) sign-in link of 2000 characters or fewer, with no user name or password in it${start ? '' : ' (or give a start command)'}`);
144
+ }
145
+ for (const f of ['unlocks', 'waits'])
146
+ if (!(isText(d[f]) && d[f].trim() && [...d[f]].length <= 120))
147
+ throw bad(f, 'must be text of 120 characters or fewer');
148
+ if (d.expires !== undefined && !(isText(d.expires) && /^\d{4}-\d{2}-\d{2}T/.test(d.expires) && !Number.isNaN(Date.parse(d.expires))))
149
+ throw bad('expires', 'must be an ISO 8601 date-time such as 2026-10-13T07:19:00Z');
150
+ for (const f of ['options', 'reply', 'recommend_value', 'default_if_silent'])
151
+ if (d[f] !== undefined)
152
+ throw bad(f, 'must be left out: a sign-in closes with signin done, not with an answer');
153
+ }
125
154
  // A ladder rung: a diagram image, or an interactive page the board frames in a sandbox.
126
155
  // A report is a page and its picture; one stored before reports had pages still loads, as a note with neither.
127
156
  const report = d.kind === 'report';
@@ -136,9 +165,9 @@ export function loadDecisions(path, board) {
136
165
  for (const f of ['options', 'reply', 'area', 'type'])
137
166
  if (d[f] !== undefined)
138
167
  throw bad(f, `must be left out: a report takes no answer${f === 'options' ? '' : ' and shows only under Reports'}`);
139
- const options = d.options ?? (report ? [] : d.type === 'evidence' ? VERDICTS : YES_NO);
140
- // An empty list is an item to look at only, unless it takes a reply: only a board another program feeds, or a report, shows those.
141
- if (!Array.isArray(options) || options.length === 1 || (!options.length && !fed && !report) || options.length > 6
168
+ const options = d.options ?? (report || signin ? [] : d.type === 'evidence' ? VERDICTS : YES_NO);
169
+ // An empty list is an item to look at only, unless it takes a reply: only a board another program feeds, a report or a sign-in shows those.
170
+ if (!Array.isArray(options) || options.length === 1 || (!options.length && !fed && !report && !signin) || options.length > 6
142
171
  || !options.every((o) => isKey(o?.value) && isText(o.label) && o.label.trim())) {
143
172
  throw bad('options', 'must be 2 to 6 items (or empty on a readonly or answers_only board), each a value of 1-128 of A-Z a-z 0-9 . _ - and a label');
144
173
  }
@@ -173,6 +202,7 @@ export function loadDecisions(path, board) {
173
202
  recommend_value: d.recommend_value, default_if_silent: d.default_if_silent, close: d.close ?? 'done', evidence: d.evidence,
174
203
  type: d.type ?? 'decision', tradeoff: d.tradeoff, link: d.link, mocks: d.mocks?.map((m) => ({ value: m.value, path: m.path })),
175
204
  ...(views && { view: d.view }), ...(report && views && { preview: d.preview }), ...(d.reply && { reply: true }), ...(d.filer && { filer: d.filer }), ...(d.area && { area: d.area }),
205
+ ...(signin && { unlocks: d.unlocks, waits: d.waits, ...(d.expires && { expires: d.expires }), ...(d.start && { start: [...d.start] }) }),
176
206
  ...(d.group && { group: d.group }), ...(d.filed && { filed: d.filed }), ...(d.decided && { decided: d.decided }),
177
207
  ...(d.steps && { steps: d.steps.map(({ title, marks }) => ({ title, ...(marks?.length && { marks }) })) }),
178
208
  ...(stored && d.media && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
package/web/index.html CHANGED
@@ -174,6 +174,11 @@
174
174
  .in-waiting .row { box-shadow: inset 3px 0 0 var(--accent), var(--shadow); }
175
175
  .in-answered .row { background: transparent; box-shadow: none; }
176
176
  .in-answered .row h3 { color: var(--muted); font-weight: 500; }
177
+ /* A sign-in: one big Open for its link, off once the link expired. */
178
+ .open-link { display: grid; place-items: center; min-height: 3.5rem; border-radius: 1rem; background: var(--accent); color: var(--on-accent);
179
+ font-size: 1.125rem; font-weight: 700; text-decoration: none; }
180
+ button.open-link:disabled { border: 1px solid var(--line); background: var(--sunk); color: var(--muted); cursor: not-allowed; }
181
+ .signin .line { margin: 0.375rem 0 0; } .signin .expiry { margin: 0.5rem 0 0; font-weight: 700; } .signin .expiry.gone { color: var(--danger); }
177
182
  .row h3, .report h3 { font-size: 1.1875rem; font-weight: 650; line-height: 1.3; letter-spacing: -0.005em; margin: 0; }
178
183
  .about, details p { margin: 0.375rem 0 0; max-width: 70ch; }
179
184
  details p { color: var(--muted); }
@@ -462,7 +467,7 @@
462
467
  el('p', { dir: 'auto' }, el('strong', {}, `${name}: `), run[name].replace(new RegExp(`^${name}:\\s*(?=\\S)`, 'i'), '')))] : []));
463
468
  }
464
469
 
465
- const NAMES = { decision: 'Decision', design: 'Design', evidence: 'Evidence', diagram: 'Diagram', interactive: 'Interactive', explainer: 'Explainer' };
470
+ const NAMES = { decision: 'Decision', design: 'Design', evidence: 'Evidence', diagram: 'Diagram', interactive: 'Interactive', explainer: 'Explainer', signin: 'Sign-in' };
466
471
  // Any other type gets the plain card until it has its own renderer; a plain question needs no name.
467
472
  const kind = (d) => (d.type === 'decision' ? '' : NAMES[d.type] ?? d.type.replace(/-/g, ' '));
468
473
  // A done-done run's outcome line is the plain result a person can now see; the title it sends only names the run.
@@ -517,6 +522,7 @@
517
522
  const brow = [update ? `Update ${d.number}` : kind(d), result ? el('span', { class: `st ${result[0]}` }, result[1]) : '',
518
523
  update && d.filed ? (isToday(d.filed) ? el('span', { class: 'st new' }, 'New') : day(d.filed)) : '', seen].filter(Boolean);
519
524
  const lead = el('div', { class: 'lead' }, x ? xBtn(d) : '', brow.length ? el('p', { class: 'eyebrow' }, ...brow) : '', el('h3', { dir: 'auto' }, name));
525
+ if (d.type === 'signin') return signin(d, lead, saved, err);
520
526
  const silent = d.default_if_silent && !ro ? `If no answer: ${d.options.find((o) => o.value === d.default_if_silent).label}` : '';
521
527
  if (design) {
522
528
  return el('article', { class: 'row design', id: `item-${d.key}` }, lead,
@@ -539,6 +545,65 @@
539
545
  ro ? '' : other(d, saved, err), err, tech(d));
540
546
  }
541
547
 
548
+ /** The time left on a sign-in link, in whole minutes. */
549
+ const expiry = (at) => {
550
+ const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000);
551
+ return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${m < 60 ? `${m} min` : `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ''}`}`;
552
+ };
553
+ /** A sign-in: the service, what it unlocks, who waits, the time left and one Open for its link. Its poster closes it with signin done. */
554
+ function signin(d, lead, saved, err) {
555
+ if (d.start) return started(d, lead, saved, err);
556
+ const gone = !!d.expires && Date.parse(d.expires) <= Date.now();
557
+ const open = saved ? '' : gone ? el('button', { class: 'open-link', disabled: '' }, 'Open')
558
+ : el('a', { class: 'open-link', href: d.link, target: '_blank', rel: 'noopener noreferrer', 'aria-label': `Open the ${d.title} sign-in` }, 'Open');
559
+ // The time left counts down on the page (data-expires); an expired link is drawn once more, with Open off.
560
+ const left = saved ? el('p', { class: 'meta' }, `Signed in at ${when(saved.at)}.`) : d.expires
561
+ ? el('p', { class: gone ? 'expiry gone' : 'expiry', ...(!gone && { 'data-expires': d.expires }) }, expiry(d.expires)) : '';
562
+ return el('article', { class: 'row signin', id: `item-${d.key}` }, el('div', {}, lead,
563
+ el('p', { class: 'line', dir: 'auto' }, `Unlocks: ${d.unlocks}`), el('p', { class: 'line', dir: 'auto' }, `Waits: ${d.waits}`), left), open, err);
564
+ }
565
+ /** What a tap-time sign-in did last, in plain words. */
566
+ const RUNS = { none: 'Each tap gets a new link.', starting: 'Getting a new link…', live: 'The sign-in page is open. Finish it there.' };
567
+ /** A sign-in whose link this host makes at each tap: Open starts it and sends a new tab to the new link, so its link never expires on the card. */
568
+ function started(d, lead, saved, err) {
569
+ const failed = d.run?.startsWith('failed: ') && d.run.slice(8);
570
+ const open = saved ? '' : FROZEN || state.readonly ? el('button', { class: 'open-link', disabled: '' }, 'Open')
571
+ : el('button', { class: 'open-link', 'aria-label': `Open the ${d.title} sign-in` }, 'Open');
572
+ if (open && !open.disabled) open.onclick = async () => {
573
+ // The tab opens on the tap itself, so a browser does not block it; it goes to the link once this host has made one.
574
+ const tab = window.open('', '_blank');
575
+ if (tab) tab.opener = null;
576
+ open.disabled = true;
577
+ open.textContent = 'Getting a new link…';
578
+ try {
579
+ const res = await fetch('/api/signin', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key }) });
580
+ const body = await res.json().catch(() => ({ error: `The server replied ${res.status}` }));
581
+ if (body.state) state = body.state;
582
+ if (!res.ok) throw new Error(body.error);
583
+ if (!body.open) tab?.close();
584
+ else if (tab) tab.location.href = body.open;
585
+ else location.href = body.open;
586
+ render();
587
+ } catch (e) {
588
+ tab?.close();
589
+ render();
590
+ shown(err).textContent = `The sign-in did not start: ${e.message}`;
591
+ }
592
+ };
593
+ const line = saved ? `Signed in at ${when(saved.at)}.` : failed ? `The last try failed: ${failed} Tap Open to try again.` : RUNS[d.run] ?? RUNS.none;
594
+ return el('article', { class: 'row signin', id: `item-${d.key}` }, el('div', {}, lead,
595
+ el('p', { class: 'line', dir: 'auto' }, `Unlocks: ${d.unlocks}`), el('p', { class: 'line', dir: 'auto' }, `Waits: ${d.waits}`),
596
+ el('p', { class: failed ? 'expiry gone' : saved ? 'meta' : 'expiry' }, line)), open, err);
597
+ }
598
+ setInterval(() => {
599
+ // While a sign-in runs on this host, the page checks often, so the card closes soon after the sign-in is done.
600
+ if (!FROZEN && state.decisions.some((d) => d.run === 'live' || d.run === 'starting')) refresh();
601
+ for (const p of document.querySelectorAll('[data-expires]')) {
602
+ if (Date.parse(p.dataset.expires) <= Date.now()) return render();
603
+ p.textContent = expiry(p.dataset.expires);
604
+ }
605
+ }, 5000);
606
+
542
607
  /** Boxes the person unfolded, kept open across redraws. */
543
608
  const unfolded = new Set();
544
609
  /** The one place to type on a card, folded behind Other answer so the buttons come first and typing is the last resort.
@@ -744,7 +809,7 @@
744
809
  /** Each item lives in one area: answered items, then the area the item names, then evidence updates, then what is only to read (explainers, pages with nothing to answer), then the open asks. */
745
810
  const area = (d) => {
746
811
  const a = isAnswered(d) ? 'answered' : d.area ?? (d.type === 'evidence' ? 'evidence'
747
- : state.readonly || d.type === 'explainer' || !(d.options.length || d.reply) ? 'reports' : 'waiting');
812
+ : state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin') ? 'reports' : 'waiting');
748
813
  // Reports lists only what explains: a page to tap through, or a narrated video. Text alone (a receipt, a status line) is a note, folded under it.
749
814
  return a === 'reports' && !state.readonly && d.type !== 'explainer' && !reads(d) ? 'notes' : a;
750
815
  };
@@ -795,7 +860,7 @@
795
860
  }
796
861
 
797
862
  /** A short row's second line: what changed, unless the headline already says it. */
798
- const sub = (d) => (d.about && d.about !== headline(d) ? d.about : NAMES[d.type] ?? d.type);
863
+ const sub = (d) => (d.type === 'signin' ? `Unlocks: ${d.unlocks}` : d.about && d.about !== headline(d) ? d.about : NAMES[d.type] ?? d.type);
799
864
  /** A short row: a picture (or the area's mark) and the title; an answered item has its answer and time under the title. */
800
865
  const compact = (d, a) => {
801
866
  const s = state.answers[d.key];
@@ -963,6 +1028,8 @@
963
1028
  const gone = new Set(state.dismissed ?? []);
964
1029
  for (const d of [...state.decisions].reverse()) if (!gone.has(d.key)) by[area(d)].push(d);
965
1030
  by.waiting.reverse();
1031
+ // A sign-in blocks someone now, so sign-ins come first.
1032
+ by.waiting.sort((x, y) => (y.type === 'signin') - (x.type === 'signin'));
966
1033
  const hidden = state.decisions.filter((d) => gone.has(d.key) && area(d) === 'waiting');
967
1034
  by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
968
1035
  const [, at, key] = /^#(waiting|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
@@ -1051,7 +1118,7 @@
1051
1118
  banner.textContent = `Can't read the board file: ${e.message}`;
1052
1119
  banner.hidden = false;
1053
1120
  };
1054
- // Another program rewrites a read-only or answers-only board's file, so the page checks it every 20 seconds and redraws only on a change.
1121
+ // Another program can change the board file (a feed, a new sign-in, signin done), so the page checks it every 20 seconds and redraws only on a change.
1055
1122
  async function refresh() {
1056
1123
  try {
1057
1124
  const res = await fetch('/api/decisions'), body = await res.json().catch(() => ({ error: `The server replied ${res.status}` }));
@@ -1069,7 +1136,7 @@
1069
1136
  if (FROZEN) (state = FROZEN), render();
1070
1137
  else call('/api/decisions').then(() => {
1071
1138
  banner.hidden = true;
1072
- if (state.readonly || state.answers_only) setInterval(refresh, 20_000);
1139
+ setInterval(refresh, 20_000);
1073
1140
  }, failed);
1074
1141
  </script>
1075
1142
  </body>
@@ -15,4 +15,5 @@
15
15
  <path class="tip" d="M346 166L370 180L346 194z"/>
16
16
  <rect class="after" x="380" y="110" width="220" height="140" rx="20"/>
17
17
  <text class="on-after" x="490" y="188" text-anchor="middle" font-size="26" font-weight="600">FILL: after</text>
18
+ <text class="label" x="320" y="300" text-anchor="middle" font-size="22" font-weight="500">FILL: as of the date, e.g. As of 15 Oct</text>
18
19
  </svg>
package/web/report.html CHANGED
@@ -24,6 +24,7 @@
24
24
  h2 { font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0; }
25
25
  p { margin: 0; }
26
26
  .lead { font-size: 1.125rem; }
27
+ .asof { font-size: 0.9375rem; color: var(--muted); }
27
28
  svg { width: 100%; height: auto; display: block; }
28
29
  svg text { fill: var(--text); font: 600 15px system-ui, sans-serif; }
29
30
  .box { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
@@ -58,6 +59,7 @@
58
59
  <h2>The answer</h2>
59
60
  <h1 id="answer">FILL: the answer in one plain sentence</h1>
60
61
  <p class="lead">FILL: one or two short lines on why it matters to the reader.</p>
62
+ <p class="asof">As of FILL: the date the facts were true, e.g. 15 Oct. Name a real date, never today or now.</p>
61
63
  <svg viewBox="0 0 340 120" role="img" aria-label="FILL: what the picture shows">
62
64
  <defs><marker id="tip" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z"/></marker></defs>
63
65
  <rect class="box" x="4" y="24" width="130" height="72" rx="12"/>