@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/README.md +104 -2
- package/cli.mjs +2327 -0
- package/commands.json +227 -0
- package/package.json +42 -4
- package/probe.mjs +479 -0
- package/releasing.md +159 -0
- package/schema.json +46 -0
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
|
+
}
|