@crvouga/personal 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/probe.mjs ADDED
@@ -0,0 +1,479 @@
1
+ // packages/fleet/src/errors.ts
2
+ class FleetUsageError extends Error {
3
+ }
4
+
5
+ // packages/fleet/src/config.ts
6
+ var CAPABILITIES = [
7
+ "github",
8
+ "vault",
9
+ "t3",
10
+ "vscode",
11
+ "toolchain"
12
+ ];
13
+ function record(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+ function fields(value, allowed) {
17
+ if (Object.keys(value).some((key) => !allowed.includes(key))) {
18
+ throw new FleetUsageError("Unknown configuration field. Run fleet schema.");
19
+ }
20
+ }
21
+ function absolutePath(value) {
22
+ return typeof value === "string" && value.startsWith("/") && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
23
+ }
24
+ function parseNode(value) {
25
+ if (!record(value))
26
+ throw new FleetUsageError("Each node must be an object.");
27
+ fields(value, ["id", "ssh", "workspace", "runtime", "required"]);
28
+ const names = nodeNames(value);
29
+ if (!absolutePath(value["workspace"]))
30
+ throw new FleetUsageError("workspace must be an absolute POSIX path.");
31
+ if (value["runtime"] !== undefined && !absolutePath(value["runtime"])) {
32
+ throw new FleetUsageError("runtime must be an absolute POSIX path to Node or Bun.");
33
+ }
34
+ return {
35
+ ...names,
36
+ workspace: value["workspace"],
37
+ ...typeof value["runtime"] === "string" ? { runtime: value["runtime"] } : {},
38
+ required: requiredCapabilities(value["required"])
39
+ };
40
+ }
41
+ function nodeNames(value) {
42
+ if (typeof value["id"] !== "string" || !/^[a-z][a-z0-9-]{0,62}$/.test(value["id"])) {
43
+ throw new FleetUsageError("Node IDs must be lowercase names (1–63 characters).");
44
+ }
45
+ if (typeof value["ssh"] !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/.test(value["ssh"])) {
46
+ throw new FleetUsageError("ssh must be a plain SSH alias or hostname, not options or user@host.");
47
+ }
48
+ return { id: value["id"], ssh: value["ssh"] };
49
+ }
50
+ function requiredCapabilities(value) {
51
+ const required = value === undefined ? ["github", "vault", "t3", "toolchain"] : value;
52
+ if (!Array.isArray(required) || required.some((item) => !CAPABILITIES.includes(item))) {
53
+ throw new FleetUsageError("required must contain only capability names from fleet schema.");
54
+ }
55
+ if (new Set(required).size !== required.length)
56
+ throw new FleetUsageError("required capabilities must be unique.");
57
+ return required;
58
+ }
59
+ function parseConfig(value) {
60
+ if (!record(value))
61
+ throw new FleetUsageError("Fleet configuration must be an object.");
62
+ fields(value, ["$schema", "version", "nodes"]);
63
+ if (value["$schema"] !== undefined && typeof value["$schema"] !== "string")
64
+ throw new FleetUsageError("$schema must be a string.");
65
+ if (value["version"] !== 1 || !Array.isArray(value["nodes"])) {
66
+ throw new FleetUsageError("Expected version 1 and a nodes array.");
67
+ }
68
+ if (value["nodes"].length < 1 || value["nodes"].length > 64) {
69
+ throw new FleetUsageError("Configure between 1 and 64 developer nodes.");
70
+ }
71
+ const nodes = value["nodes"].map(parseNode);
72
+ if (new Set(nodes.map((node) => node.id)).size !== nodes.length) {
73
+ throw new FleetUsageError("Node IDs must be unique.");
74
+ }
75
+ return { version: 1, nodes };
76
+ }
77
+
78
+ // packages/fleet/src/fingerprint.ts
79
+ import { readFile as readFile2, mkdir, writeFile } from "node:fs/promises";
80
+ import { join as join2 } from "node:path";
81
+
82
+ // packages/fleet/src/input-hashes.ts
83
+ import { createHash } from "node:crypto";
84
+ import { readFile, readdir } from "node:fs/promises";
85
+ import { join } from "node:path";
86
+ var INPUT_FILES = [
87
+ "package.json",
88
+ "bun.lock",
89
+ "mise.toml",
90
+ "mise.lock"
91
+ ];
92
+ var FINGERPRINT_FILES = [
93
+ ...INPUT_FILES,
94
+ "workspace-manifests",
95
+ "mise-sidecars"
96
+ ];
97
+ async function fileHash(path) {
98
+ try {
99
+ return digest(await readFile(path));
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+ async function manifestHash(root) {
105
+ try {
106
+ const entries = await readdir(join(root, "packages"), {
107
+ withFileTypes: true
108
+ });
109
+ const hashes = [];
110
+ for (const entry of entries.filter((item) => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
111
+ hashes.push(`${entry.name}:${await fileHash(join(root, "packages", entry.name, "package.json"))}`);
112
+ }
113
+ return digest(hashes.join(`
114
+ `));
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+ async function sidecarHash(root) {
120
+ try {
121
+ const files = await readdir(join(root, ".mise/locks"), {
122
+ recursive: true,
123
+ withFileTypes: true
124
+ });
125
+ if (files.length > 4096 || files.some((entry) => entry.isSymbolicLink()))
126
+ return null;
127
+ const hashes = [];
128
+ const paths = files.filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name)).sort();
129
+ for (const path of paths) {
130
+ const hash = await fileHash(path);
131
+ if (hash === null)
132
+ return null;
133
+ const relative = path.slice(join(root, ".mise/locks").length).replaceAll("\\", "/");
134
+ hashes.push(`${relative}:${hash}`);
135
+ }
136
+ return digest(hashes.join(`
137
+ `));
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+ function digest(value) {
143
+ return createHash("sha256").update(value).digest("hex");
144
+ }
145
+
146
+ // packages/fleet/src/fingerprint.ts
147
+ var RECEIPT = "node_modules/.fleet-sync.json";
148
+ async function fingerprint(root, run) {
149
+ const files = {};
150
+ for (const name of INPUT_FILES)
151
+ files[name] = await fileHash(join2(root, name));
152
+ files["workspace-manifests"] = await manifestHash(root);
153
+ files["mise-sidecars"] = await sidecarHash(root);
154
+ const head = await run(["git", "rev-parse", "HEAD"], { cwd: root });
155
+ return {
156
+ head: head.ok && /^[a-f0-9]{40,64}\s*$/.test(head.stdout) ? head.stdout.trim() : null,
157
+ files
158
+ };
159
+ }
160
+ async function dependencyCheck(root, current) {
161
+ const remedy = {
162
+ action: "Review fleet sync, then explicitly apply a frozen toolchain/dependency sync."
163
+ };
164
+ try {
165
+ const receipt = JSON.parse(await readFile2(join2(root, RECEIPT), "utf8"));
166
+ const matches = record(receipt) && receipt["version"] === 1 && JSON.stringify(receipt["files"]) === JSON.stringify(current.files);
167
+ return {
168
+ id: "dependencies",
169
+ status: matches ? "pass" : "fail",
170
+ summary: matches ? "Last successful frozen sync matches input hashes; not a full integrity audit." : "Dependency sync receipt is stale or invalid.",
171
+ ...matches ? {} : { remedy }
172
+ };
173
+ } catch {
174
+ return {
175
+ id: "dependencies",
176
+ status: "warn",
177
+ summary: "No frozen-sync receipt; installed dependencies are unverified.",
178
+ remedy
179
+ };
180
+ }
181
+ }
182
+ async function writeReceipt(root, current) {
183
+ await mkdir(join2(root, "node_modules"), { recursive: true });
184
+ await writeFile(join2(root, RECEIPT), JSON.stringify({ version: 1, files: current.files }), { mode: 384 });
185
+ }
186
+
187
+ // packages/fleet/src/auth-checks.ts
188
+ import { join as join3 } from "node:path";
189
+ async function authCheck(capability, run, root) {
190
+ if (capability === "t3")
191
+ return t3Checks(run);
192
+ const spec = {
193
+ github: {
194
+ command: ["gh", "auth", "status", "--active"],
195
+ action: "Authenticate on this node with gh auth login, then gh auth setup-git."
196
+ },
197
+ vault: {
198
+ command: ["vault", "token", "lookup", "-format=json"],
199
+ action: "Authenticate to the configured Vault on this node. Do not copy a token from another computer."
200
+ },
201
+ vscode: {
202
+ command: ["code", "--list-extensions"],
203
+ action: "Install the VS Code CLI on PATH and ms-vscode-remote.remote-ssh in the local editor profile."
204
+ },
205
+ toolchain: {
206
+ command: ["bun", join3(root, "scripts/dev/doctor.ts"), "--json"],
207
+ action: "Run ws tools check --json; use the shared mise setup to reconcile tools."
208
+ }
209
+ }[capability];
210
+ const result = await run(spec.command, {
211
+ cwd: root,
212
+ timeoutMs: capability === "toolchain" ? 90000 : 15000
213
+ });
214
+ const extensions = result.stdout.toLowerCase().split(/\s+/);
215
+ const ok = result.ok && (capability !== "vscode" || extensions.includes("ms-vscode-remote.remote-ssh"));
216
+ return [
217
+ {
218
+ id: `auth:${capability}`,
219
+ status: ok ? "pass" : "fail",
220
+ summary: ok ? `${capability} check succeeded in this process context.` : `${capability} check failed (${result.reason}); no credential output retained.`,
221
+ ...ok ? {} : { remedy: { action: spec.action } }
222
+ }
223
+ ];
224
+ }
225
+ async function t3Checks(run) {
226
+ const config = await run(["t3", "status", "--json"]);
227
+ const checks = [t3Settings(config)];
228
+ const live = await run(["t3", "environment", "list", "--json"]);
229
+ checks.push({
230
+ id: "t3:api",
231
+ status: live.ok ? "pass" : "fail",
232
+ summary: live.ok ? "Local T3 API answered a read-only environment listing; peer connectivity is not inferred." : "Local T3 API did not answer the environment listing.",
233
+ ...!live.ok ? {
234
+ remedy: {
235
+ action: "Check t3 service status and t3 status. Inspect linked peers in T3 Settings → Connections; relink only with operator approval."
236
+ }
237
+ } : {}
238
+ });
239
+ checks.push({
240
+ id: "t3:end-to-end",
241
+ status: "skip",
242
+ summary: "Relay traversal and remote peer authorization require an operator connection test; local settings are not proof."
243
+ });
244
+ return checks;
245
+ }
246
+ function t3Settings(config) {
247
+ const fallback = {
248
+ id: "t3:settings",
249
+ status: "fail",
250
+ summary: "T3 settings unavailable or unsupported CLI response.",
251
+ remedy: {
252
+ action: "Run t3 status --json on this node. Complete t3 up --connect as its operator if needed."
253
+ }
254
+ };
255
+ try {
256
+ const data = JSON.parse(config.stdout);
257
+ if (config.ok && record(data) && record(data["connect"])) {
258
+ const enabled = data["connect"]["enabled"] === true && data["connect"]["revoked"] === false;
259
+ return {
260
+ id: "t3:settings",
261
+ status: enabled ? "pass" : "fail",
262
+ summary: enabled ? "T3 Connect enabled and not marked revoked (settings only)." : "T3 Connect disabled, revoked, or unknown.",
263
+ ...!enabled ? {
264
+ remedy: {
265
+ action: "Review t3 status, then complete t3 up --connect as the operator. Never share pairing tokens."
266
+ }
267
+ } : {}
268
+ };
269
+ }
270
+ } catch {}
271
+ return fallback;
272
+ }
273
+
274
+ // packages/fleet/src/probe.ts
275
+ async function probe(root, required, run) {
276
+ const current = await fingerprint(root, run);
277
+ const checks = [
278
+ {
279
+ id: "workspace",
280
+ status: Object.values(current.files).every(Boolean) && current.head ? "pass" : "fail",
281
+ summary: "Checkout revision and shared dependency/toolchain input availability."
282
+ }
283
+ ];
284
+ const dirty = await run(["git", "status", "--porcelain"], { cwd: root });
285
+ checks.push({
286
+ id: "git:clean",
287
+ status: dirty.ok ? dirty.stdout.trim() ? "warn" : "pass" : "fail",
288
+ summary: dirty.ok && !dirty.stdout.trim() ? "Working tree is clean." : "Working tree is dirty or unreadable; sync --pull will refuse."
289
+ });
290
+ checks.push(await dependencyCheck(root, current));
291
+ for (const capability of [
292
+ "github",
293
+ "vault",
294
+ "t3",
295
+ "vscode",
296
+ "toolchain"
297
+ ]) {
298
+ if (required.includes(capability))
299
+ checks.push(...await authCheck(capability, run, root));
300
+ else
301
+ checks.push({
302
+ id: `auth:${capability}`,
303
+ status: "skip",
304
+ summary: "Not required by this node configuration."
305
+ });
306
+ }
307
+ return {
308
+ version: 1,
309
+ platform: process.platform,
310
+ fingerprint: current,
311
+ checks
312
+ };
313
+ }
314
+
315
+ // packages/fleet/src/runner.ts
316
+ import { spawn } from "node:child_process";
317
+ var MAX_OUTPUT_BYTES = 1024 * 1024;
318
+ function terminate(child) {
319
+ try {
320
+ if (process.platform !== "win32" && child.pid)
321
+ process.kill(-child.pid, "SIGKILL");
322
+ else
323
+ child.kill("SIGKILL");
324
+ } catch {}
325
+ }
326
+ function runCommand(command, options = {}) {
327
+ return new Promise((resolve) => {
328
+ const child = spawn(command[0], command.slice(1), {
329
+ cwd: options.cwd,
330
+ env: {
331
+ ...process.env,
332
+ ...options.env,
333
+ GIT_TERMINAL_PROMPT: "0",
334
+ CI: "1"
335
+ },
336
+ stdio: "pipe",
337
+ windowsHide: true,
338
+ detached: process.platform !== "win32"
339
+ });
340
+ let stdout = "";
341
+ let stderr = "";
342
+ let bytes = 0;
343
+ let finished = false;
344
+ let reason = "exit";
345
+ const finish = (ok) => {
346
+ if (finished)
347
+ return;
348
+ finished = true;
349
+ clearTimeout(timer);
350
+ child.stdin.destroy();
351
+ child.stdout.destroy();
352
+ child.stderr.destroy();
353
+ resolve({ ok, stdout, stderr, reason });
354
+ };
355
+ const abort = (cause) => {
356
+ reason = cause;
357
+ terminate(child);
358
+ finish(false);
359
+ };
360
+ const timer = setTimeout(() => {
361
+ abort("timeout");
362
+ }, options.timeoutMs ?? 15000);
363
+ const capture = (stream, chunk) => {
364
+ if (finished)
365
+ return;
366
+ bytes += Buffer.byteLength(chunk);
367
+ if (bytes > MAX_OUTPUT_BYTES) {
368
+ abort("overflow");
369
+ return;
370
+ }
371
+ if (stream === "stdout")
372
+ stdout += chunk;
373
+ else
374
+ stderr += chunk;
375
+ };
376
+ child.stdout.setEncoding("utf8").on("data", (chunk) => capture("stdout", chunk));
377
+ child.stderr.setEncoding("utf8").on("data", (chunk) => capture("stderr", chunk));
378
+ child.stdin.on("error", () => {
379
+ return;
380
+ });
381
+ child.on("error", () => {
382
+ abort("missing");
383
+ });
384
+ child.on("close", (code) => {
385
+ finish(code === 0 && reason === "exit");
386
+ });
387
+ child.stdin.end(options.input);
388
+ });
389
+ }
390
+
391
+ // packages/fleet/src/sync.ts
392
+ import { unlink } from "node:fs/promises";
393
+ import { join as join4 } from "node:path";
394
+ async function syncWorkspace(root, pull, run) {
395
+ const before = await fingerprint(root, run);
396
+ if (!completeFingerprint(before)) {
397
+ return failed("Not a complete workspace checkout. Follow fleet onboard first.");
398
+ }
399
+ if (pull) {
400
+ const dirty = await run(["git", "status", "--porcelain"], { cwd: root });
401
+ const branch = await run(["git", "symbolic-ref", "--quiet", "HEAD"], {
402
+ cwd: root
403
+ });
404
+ if (!dirty.ok || dirty.stdout.trim() || !branch.ok)
405
+ return failed("Refusing pull: dirty, detached, or unreadable checkout. Reconcile manually.");
406
+ }
407
+ await unlink(join4(root, "node_modules/.fleet-sync.json")).catch(() => {
408
+ return;
409
+ });
410
+ if (pull) {
411
+ const result = await run(["git", "pull", "--ff-only"], {
412
+ cwd: root,
413
+ timeoutMs: 120000
414
+ });
415
+ if (!result.ok)
416
+ return failed("Fast-forward pull failed. Inspect upstream/network/auth manually; nothing was reset.");
417
+ }
418
+ const expected = await fingerprint(root, run);
419
+ const result = await run([
420
+ "mise",
421
+ "exec",
422
+ "--locked",
423
+ "bun",
424
+ "--",
425
+ "bun",
426
+ "scripts/dev/setup.ts",
427
+ "--non-interactive"
428
+ ], { cwd: root, timeoutMs: 1200000 });
429
+ if (!result.ok)
430
+ return failed(`Shared toolchain/dependency sync failed (${result.reason}). Inspect setup locally; partial installs are possible.`);
431
+ const after = await fingerprint(root, run);
432
+ if (JSON.stringify(expected) !== JSON.stringify(after))
433
+ return failed("Checkout inputs changed during sync. Retry from a stable checkout.");
434
+ await writeReceipt(root, after);
435
+ return [
436
+ {
437
+ id: "sync",
438
+ status: "pass",
439
+ summary: "Shared setup succeeded; frozen-sync receipt recorded. Auth and live connectivity still need doctor checks."
440
+ }
441
+ ];
442
+ }
443
+ function failed(summary) {
444
+ return [
445
+ {
446
+ id: "sync",
447
+ status: "fail",
448
+ summary,
449
+ remedy: {
450
+ action: "Resolve locally using scripts/dev/README.md, then review and retry fleet sync."
451
+ }
452
+ }
453
+ ];
454
+ }
455
+ function completeFingerprint(value) {
456
+ return value.head !== null && Object.values(value.files).every(Boolean);
457
+ }
458
+
459
+ // packages/fleet/src/remote-entry.ts
460
+ async function main() {
461
+ const raw = JSON.parse(Buffer.from(process.argv.at(-1) ?? "", "base64").toString());
462
+ if (!record(raw) || raw["action"] !== "probe" && raw["action"] !== "sync")
463
+ throw new Error("Invalid request.");
464
+ const node = parseConfig({ version: 1, nodes: [raw["node"]] }).nodes[0];
465
+ if (raw["action"] === "sync") {
466
+ if (raw["apply"] !== true)
467
+ throw new Error("Sync requires explicit authorization.");
468
+ const checks = await syncWorkspace(node.workspace, raw["pull"] === true, runCommand);
469
+ process.stdout.write(JSON.stringify({ version: 1, checks }));
470
+ } else
471
+ process.stdout.write(JSON.stringify(await probe(node.workspace, node.required, runCommand)));
472
+ }
473
+ await main().catch(() => {
474
+ process.stdout.write(JSON.stringify({
475
+ version: 1,
476
+ error: "Remote operation failed; inspect node locally."
477
+ }));
478
+ process.exitCode = 1;
479
+ });
package/releasing.md ADDED
@@ -0,0 +1,159 @@
1
+ # Automated npm releases
2
+
3
+ The source workspace stays private. Only the allowlisted contents of
4
+ `packages/personal-cli/dist` are packed as **`@crvouga/personal`**.
5
+
6
+ ## One-time bootstrap
7
+
8
+ Node, npm and Bun must be on PATH through the shared mise setup. npm 11.15+ is
9
+ needed for the bootstrap's `npm trust` command. Start with a read-only plan:
10
+
11
+ ```sh
12
+ bash scripts/dev/bootstrap.sh
13
+ exec "$SHELL" -l
14
+ ```
15
+
16
+ The machine bootstrap needs no Bun or npm, installs the shared locked mise
17
+ toolchain and frozen workspace dependencies, and configures persistent shell
18
+ PATH. On native Windows use `& ./scripts/dev/bootstrap.ps1`, then reopen your
19
+ terminal. See [machine setup](../../scripts/dev/README.md) for new/remote nodes.
20
+ After refreshing your terminal, run:
21
+
22
+ ```sh
23
+ bun run personal:release:plan
24
+ bun run personal:publish:bootstrap --apply --yes --npm-login
25
+ ```
26
+
27
+ Release progress on stderr identifies each stage (`npm-version`, `build`,
28
+ `pack`, `consumer-install`, consumer commands and publisher verification).
29
+ Failures name the stage without exposing raw subprocess output or credentials.
30
+ The npm runner supports mise shims, shell launchers and native executables;
31
+ Windows `.cmd` launchers use npm's adjacent JavaScript entry point through Node.
32
+ To validate the complete build/pack/anonymous-consumer path without logging in,
33
+ publishing or changing trusted publishers, run:
34
+
35
+ ```sh
36
+ bun run personal:build
37
+ bun run --cwd packages/personal-cli smoke
38
+ ```
39
+
40
+ The bootstrap builds and installs/tests the exact tarball in an anonymous isolated
41
+ consumer, prints npm's account authorization URL, publishes the package if necessary,
42
+ and configures/verifies its GitHub trusted publisher. Approve npm login and any
43
+ npm account/2FA approval in your own terminal/browser. No npm password, recovery
44
+ code or long-lived publishing token needs to be supplied to this project.
45
+ The trusted publisher is **`crvouga/personal` / `ci.yml`**, with direct publishing
46
+ permission. npm validates the calling workflow for reusable workflows, so this
47
+ must be `ci.yml`, not the reusable `release-personal-cli.yml` filename.
48
+
49
+ Bootstrap is retryable: if content is already published, it only checks/configures
50
+ the publisher. Existing publishers are preserved; no connection is revoked or
51
+ replaced. npm account authorization uses an isolated temporary 0600 npmrc, which
52
+ is removed with its logs/cache when the command exits. Initial publication also
53
+ inherits your terminal so npm can request its publishing-specific 2FA approval.
54
+ Global npm login/config is
55
+ not changed. A hard process kill can leave a temporary directory; remove any
56
+ abandoned `personal-npm-auth-*` directory in your OS temporary directory.
57
+
58
+ ### Remote Mac / SSH browser approval
59
+
60
+ Run the bootstrap **on the Mac Mini** in an interactive remote terminal. If you
61
+ connect with SSH, use `ssh -t YOUR_ALIAS` to allocate a terminal. No browser is
62
+ needed on the Mini, and no port forwarding or copying npm credentials is needed.
63
+
64
+ 1. Run `bun run personal:publish:bootstrap --apply --yes --npm-login` on the Mini.
65
+ 2. When npm prints its HTTPS login URL, copy/open that **new URL on your MacBook
66
+ Air**, sign in to npm and complete the account/2FA approval there.
67
+ 3. **Leave the Mini's terminal running**. It waits for npm's approval and resumes
68
+ automatically; no Enter-to-open-browser prompt is used. Ctrl+C cancels login.
69
+ 4. Publishing may print another approval URL or ask for a one-time code. Open
70
+ that URL on the Air too, or enter the requested code in the Mini's terminal.
71
+
72
+ Browser launch is disabled for the entire isolated npm session, including
73
+ publish-time web 2FA and trusted-publisher setup. npm update notices/progress are
74
+ disabled too; keep npm versions owned by the shared mise lock, not a global
75
+ `npm install -g`. After cancellation or expiry, rerun the command and use its new
76
+ URL, not an earlier session's URL. Treat approval links as private; never paste
77
+ them into reports or shared logs. The browser approval is not proof of publication;
78
+ wait for registry-integrity and trusted-publisher verification to finish.
79
+
80
+ ## Vault username/password login
81
+
82
+ If a suitable npm publishing token already exists in Vault, use:
83
+
84
+ ```sh
85
+ bun run personal:publish:bootstrap --apply --yes --login
86
+ ```
87
+
88
+ This prompts for **Vault username and password**, not a Vault token. The default
89
+ username is `crvouga`; set `PERSONAL_VAULT_USERNAME` to change it. Login creates a
90
+ temporary in-memory userpass session, reads the requested credential and revokes
91
+ the session immediately afterward. Nothing is written to `~/.vault-token`.
92
+ `PERSONAL_VAULT_PASSWORD_FILE` supports a private secret-delivery file for agents;
93
+ the caller owns deleting that file immediately after use. Never pass passwords as
94
+ command arguments. Noninteractive calls never prompt.
95
+
96
+ Defaults: Vault `https://vault.chrisvouga.dev`, document
97
+ `secret/data/personal/prd`, field `NPM_TOKEN`. Select an **existing** field/document
98
+ using `PERSONAL_NPM_TOKEN_KEY` / `PERSONAL_NPM_VAULT_PATH`. The token must permit
99
+ creating/publishing `@crvouga/personal` and satisfy npm's unattended-publish/2FA
100
+ policy. Recovery codes are not publishing credentials. Missing fields fail
101
+ clearly; no code guesses another secret, seeds values or changes Vault policy.
102
+ Vault auth, policy and secret changes remain exclusively OpenTofu-owned.
103
+
104
+ Existing npm `NODE_AUTH_TOKEN` / `NPM_TOKEN` are also supported for automation.
105
+ Local Vault access always uses username/password, never a user-supplied Vault
106
+ token or a saved `~/.vault-token`. `--login` explicitly chooses username/password
107
+ even when an npm credential is already injected. A publishing
108
+ token is passed only in the npm child environment; its temporary npmrc contains
109
+ an environment-variable reference, not the token itself.
110
+
111
+ ## CI: automatic publishing after checks
112
+
113
+ The main **CI** workflow validates the CLI on Linux/macOS/Windows and Node
114
+ 22/24/26, including installing the packed distribution into a clean directory
115
+ with anonymous registry configuration and running it on real Node. The required
116
+ **Check** includes this job. After Check/Fleet pass on main, the reusable
117
+ `release-personal-cli.yml` workflow:
118
+
119
+ 1. Reads the public registry and hashes the release inputs.
120
+ 2. Skips publication when the published content hash already matches.
121
+ 3. Uses `0.1.0` initially, then automatically increments the current stable patch
122
+ version. A higher source manifest version deliberately selects a minor/major.
123
+ 4. Builds, allowlist-checks, packs and consumer-tests the exact tarball.
124
+ 5. Publishes using npm's **GitHub OIDC trusted publishing**, without npm secrets.
125
+ 6. Confirms the content hash, SHA-512 integrity and latest tag, with bounded
126
+ read-only retries for registry propagation. Unconfirmed outcomes fail rather
127
+ than blindly republish.
128
+
129
+ Publishing is serialized with non-cancelling concurrency. Dispatch **CI** on main
130
+ to retry. PRs, merge queues, failed checks and non-main dispatches never publish.
131
+ Public-source CI includes npm provenance. Local bootstrap records its revision
132
+ and dirty-checkout status and does not claim CI provenance. Registry versions
133
+ are the checkpoint; no version-bump commits, tags or GitHub write access is needed.
134
+ Atlas and Mockingbird informed the bootstrap/trusted-publisher and exact-consumer
135
+ validation design; their accounts/publishers are never modified.
136
+
137
+ For an explicitly selected existing Vault-backed npm token instead of OIDC, set
138
+ the **repository variable** `PERSONAL_NPM_TOKEN_KEY` to its field name, never its
139
+ value. CI then uses existing GitHub → Vault OIDC (`jwt` / `github-actions`) to read
140
+ that one field from `secret/data/personal/prd`. No duplicate GitHub secret is
141
+ created. Leave the variable unset for default tokenless npm publishing.
142
+
143
+ ## Recovery and verification
144
+
145
+ If publication is unconfirmed, rerun the **plan** first. Never guess a new version
146
+ or blindly repeat a publish. Bootstrap can resume publisher setup after a package
147
+ was published successfully. npm account access is a prerequisite for first
148
+ publication; Vault login alone cannot grant npm publishing access.
149
+
150
+ Verify the released consumer independently:
151
+
152
+ ```sh
153
+ npx --yes @crvouga/personal@latest commands --json
154
+ npx --yes @crvouga/personal@latest fleet doctor --all --json
155
+ ```
156
+
157
+ Do not claim release or green CI based on local checks. Deployment of the CI
158
+ workflow requires these source changes to land on main, and remote fleet coverage
159
+ requires actual locally configured SSH aliases and checkout paths.
package/schema.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Developer fleet roster (not infrastructure inventory)",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["version", "nodes"],
7
+ "properties": {
8
+ "$schema": { "type": "string" },
9
+ "version": { "const": 1 },
10
+ "nodes": {
11
+ "type": "array",
12
+ "minItems": 1,
13
+ "maxItems": 64,
14
+ "items": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "required": ["id", "ssh", "workspace"],
18
+ "properties": {
19
+ "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,62}$" },
20
+ "ssh": {
21
+ "type": "string",
22
+ "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$"
23
+ },
24
+ "workspace": {
25
+ "type": "string",
26
+ "maxLength": 4096,
27
+ "pattern": "^/[^\\u0000-\\u001f\\u007f]*$"
28
+ },
29
+ "runtime": {
30
+ "type": "string",
31
+ "maxLength": 4096,
32
+ "pattern": "^/[^\\u0000-\\u001f\\u007f]*$"
33
+ },
34
+ "required": {
35
+ "type": "array",
36
+ "uniqueItems": true,
37
+ "default": ["github", "vault", "t3", "toolchain"],
38
+ "items": {
39
+ "enum": ["github", "vault", "t3", "vscode", "toolchain"]
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }