@solcreek/cli 0.4.21 → 0.4.22
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/CHANGELOG.md +21 -0
- package/dist/commands/dashboard.d.ts +21 -0
- package/dist/commands/dashboard.js +72 -0
- package/dist/commands/deploy.d.ts +10 -0
- package/dist/commands/deploy.js +252 -0
- package/dist/commands/dev.d.ts +13 -0
- package/dist/commands/dev.js +77 -2
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +158 -2
- package/dist/commands/logs.d.ts +12 -0
- package/dist/commands/logs.js +69 -1
- package/dist/commands/restart.d.ts +26 -0
- package/dist/commands/restart.js +55 -0
- package/dist/commands/rollback.d.ts +13 -0
- package/dist/commands/rollback.js +188 -1
- package/dist/commands/stop.d.ts +26 -0
- package/dist/commands/stop.js +65 -0
- package/dist/commands/top.d.ts +28 -0
- package/dist/commands/top.js +171 -0
- package/dist/dev/creekd-runner.d.ts +22 -0
- package/dist/dev/creekd-runner.js +188 -0
- package/dist/index.js +8 -0
- package/dist/utils/creekd-client.d.ts +152 -0
- package/dist/utils/creekd-client.js +144 -0
- package/dist/utils/gitignore.d.ts +2 -0
- package/dist/utils/gitignore.js +32 -0
- package/dist/utils/hostkey.d.ts +39 -0
- package/dist/utils/hostkey.js +84 -0
- package/dist/utils/hosts.d.ts +70 -0
- package/dist/utils/hosts.js +90 -0
- package/dist/utils/local-cache.d.ts +69 -0
- package/dist/utils/local-cache.js +100 -0
- package/dist/utils/nextjs.d.ts +4 -2
- package/dist/utils/nextjs.js +107 -38
- package/dist/utils/prepare-bundle.js +1 -1
- package/dist/utils/top-format.d.ts +4 -0
- package/dist/utils/top-format.js +32 -0
- package/dist/utils/watch.d.ts +81 -0
- package/dist/utils/watch.js +87 -0
- package/package.json +2 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight creekd admin API client for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Uses plain fetch (no openapi-fetch dep) with types matching
|
|
5
|
+
* the OpenAPI spec. The CLI only needs a handful of endpoints.
|
|
6
|
+
*/
|
|
7
|
+
export class CreekdApiError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
code;
|
|
10
|
+
constructor(status, code) {
|
|
11
|
+
super(`creekd: ${code} (${status})`);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "CreekdApiError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Thrown specifically on 412 Precondition Failed (If-Match
|
|
19
|
+
* mismatch). Carries the daemon's CURRENT rv so the caller can
|
|
20
|
+
* decide between (a) prompting the user to refresh, (b) auto-
|
|
21
|
+
* retrying with the fresh rv when --bypass-rv was passed, or
|
|
22
|
+
* (c) emitting a structured machine-readable error to JSON mode.
|
|
23
|
+
*
|
|
24
|
+
* Per DESIGN-self-host-state.md §"First-party CLI MUST send
|
|
25
|
+
* If-Match": "On 412, CLI surfaces a structured prompt and does
|
|
26
|
+
* NOT auto-retry by default."
|
|
27
|
+
*/
|
|
28
|
+
export class CreekdResourceVersionMismatchError extends CreekdApiError {
|
|
29
|
+
currentResourceVersion;
|
|
30
|
+
attemptedResourceVersion;
|
|
31
|
+
constructor(currentResourceVersion, attemptedResourceVersion) {
|
|
32
|
+
super(412, "resource_version_mismatch");
|
|
33
|
+
this.currentResourceVersion = currentResourceVersion;
|
|
34
|
+
this.attemptedResourceVersion = attemptedResourceVersion;
|
|
35
|
+
this.name = "CreekdResourceVersionMismatchError";
|
|
36
|
+
this.message = `creekd: resource_version_mismatch (current=${currentResourceVersion}, attempted=${attemptedResourceVersion})`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const DEFAULT_URL = "http://127.0.0.1:9080";
|
|
40
|
+
export function getCreekdUrl() {
|
|
41
|
+
return process.env.CREEKD_URL || process.env.CREEKCTL_SERVER || DEFAULT_URL;
|
|
42
|
+
}
|
|
43
|
+
export function getCreekdToken() {
|
|
44
|
+
return process.env.CREEKD_TOKEN || process.env.CREEKCTL_TOKEN || "";
|
|
45
|
+
}
|
|
46
|
+
export class CreekdClient {
|
|
47
|
+
token;
|
|
48
|
+
baseUrl;
|
|
49
|
+
constructor(baseUrl = getCreekdUrl(), token = getCreekdToken()) {
|
|
50
|
+
this.token = token;
|
|
51
|
+
// Normalise: add http:// for bare host:port. Mirrors
|
|
52
|
+
// utils/hostkey.ts's normalizeAdminAddr so the two callers
|
|
53
|
+
// accept the same shape from hosts.json.
|
|
54
|
+
if (!/^https?:\/\//.test(baseUrl)) {
|
|
55
|
+
baseUrl = "http://" + baseUrl;
|
|
56
|
+
}
|
|
57
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
58
|
+
}
|
|
59
|
+
async listApps() {
|
|
60
|
+
const resp = await this.get("/v1/apps");
|
|
61
|
+
return resp.apps;
|
|
62
|
+
}
|
|
63
|
+
async getApp(id) {
|
|
64
|
+
return this.get(`/v1/apps/${encodeURIComponent(id)}`);
|
|
65
|
+
}
|
|
66
|
+
async getStats(id) {
|
|
67
|
+
return this.get(`/v1/apps/${encodeURIComponent(id)}/stats`);
|
|
68
|
+
}
|
|
69
|
+
async getAppLogs(id, tail = 100) {
|
|
70
|
+
const res = await this.request("GET", `/v1/apps/${encodeURIComponent(id)}/logs?tail=${tail}`);
|
|
71
|
+
return res.text();
|
|
72
|
+
}
|
|
73
|
+
async stopApp(id, opts = {}) {
|
|
74
|
+
await this.request("DELETE", `/v1/apps/${encodeURIComponent(id)}`, undefined, opts.ifMatch);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Spawn a brand-new app. POST /v1/apps. Creation is not
|
|
78
|
+
* spec-mutating in the rv sense — there's no prior version to
|
|
79
|
+
* If-Match against — so ifMatch is intentionally NOT a parameter.
|
|
80
|
+
*/
|
|
81
|
+
async spawnApp(body) {
|
|
82
|
+
return this.post(`/v1/apps`, body);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Blue-green deploy of an existing app. POST /v1/apps/{id}/deploy.
|
|
86
|
+
* Spec-mutating; pass ifMatch sourced from the local cache (or a
|
|
87
|
+
* fresh getApp) — 412 surfaces as CreekdResourceVersionMismatchError.
|
|
88
|
+
*/
|
|
89
|
+
async deployApp(id, body, opts = {}) {
|
|
90
|
+
const res = await this.request("POST", `/v1/apps/${encodeURIComponent(id)}/deploy`, body, opts.ifMatch);
|
|
91
|
+
return res.json();
|
|
92
|
+
}
|
|
93
|
+
async restartApp(id) {
|
|
94
|
+
// Restart is an OPERATION, not a spec mutation per
|
|
95
|
+
// DESIGN-self-host-state.md §"Mutex granularity" — supervisor
|
|
96
|
+
// restarts an existing app in place, neither generation nor rv
|
|
97
|
+
// bumps. No If-Match needed.
|
|
98
|
+
return this.post(`/v1/apps/${encodeURIComponent(id)}/restart`, {});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Roll back to the target release seq. Spec-mutating — accepts
|
|
102
|
+
* If-Match. Throws CreekdResourceVersionMismatchError on 412.
|
|
103
|
+
*/
|
|
104
|
+
async rollbackApp(id, toSeq, opts = {}) {
|
|
105
|
+
const path = `/v1/apps/${encodeURIComponent(id)}/rollback?to=${toSeq}`;
|
|
106
|
+
const res = await this.request("POST", path, undefined, opts.ifMatch);
|
|
107
|
+
return res.json();
|
|
108
|
+
}
|
|
109
|
+
async get(path) {
|
|
110
|
+
const res = await this.request("GET", path);
|
|
111
|
+
return res.json();
|
|
112
|
+
}
|
|
113
|
+
async post(path, body) {
|
|
114
|
+
const res = await this.request("POST", path, body);
|
|
115
|
+
return res.json();
|
|
116
|
+
}
|
|
117
|
+
async request(method, path, body, ifMatch) {
|
|
118
|
+
const headers = {};
|
|
119
|
+
if (this.token)
|
|
120
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
121
|
+
if (body !== undefined)
|
|
122
|
+
headers["Content-Type"] = "application/json";
|
|
123
|
+
if (ifMatch !== undefined)
|
|
124
|
+
headers["If-Match"] = ifMatch;
|
|
125
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
126
|
+
method,
|
|
127
|
+
headers,
|
|
128
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const err = await res.json().catch(() => ({ code: "unknown", error: res.statusText }));
|
|
132
|
+
// 412 carries the daemon's current rv in the body so the
|
|
133
|
+
// caller can decide whether to refresh + retry. Surface as
|
|
134
|
+
// the typed subclass — generic error handlers still catch it
|
|
135
|
+
// via instanceof CreekdApiError.
|
|
136
|
+
if (res.status === 412 && err.code === "resource_version_mismatch") {
|
|
137
|
+
throw new CreekdResourceVersionMismatchError(err.currentResourceVersion ?? "", ifMatch ?? "");
|
|
138
|
+
}
|
|
139
|
+
throw new CreekdApiError(res.status, err.code);
|
|
140
|
+
}
|
|
141
|
+
return res;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=creekd-client.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, readFileSync, appendFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const ENTRIES = [
|
|
4
|
+
".creek",
|
|
5
|
+
".agent",
|
|
6
|
+
".agents",
|
|
7
|
+
".augment",
|
|
8
|
+
".claude",
|
|
9
|
+
".cline",
|
|
10
|
+
".cursor",
|
|
11
|
+
".github/copilot*",
|
|
12
|
+
".kilocode",
|
|
13
|
+
".kiro",
|
|
14
|
+
".qoder",
|
|
15
|
+
".qwen",
|
|
16
|
+
".roo",
|
|
17
|
+
".trae",
|
|
18
|
+
".windsurf",
|
|
19
|
+
];
|
|
20
|
+
export function ensureGitignoreEntries(dir) {
|
|
21
|
+
const gitignorePath = join(dir, ".gitignore");
|
|
22
|
+
const existing = existsSync(gitignorePath)
|
|
23
|
+
? readFileSync(gitignorePath, "utf-8")
|
|
24
|
+
: "";
|
|
25
|
+
const lines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
26
|
+
const missing = ENTRIES.filter((entry) => !lines.has(entry));
|
|
27
|
+
if (!missing.length)
|
|
28
|
+
return;
|
|
29
|
+
const block = `\n# Creek & AI agent configs\n${missing.join("\n")}\n`;
|
|
30
|
+
appendFileSync(gitignorePath, block);
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=gitignore.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOFU hostkey discovery — call creekd's GET /v1/hostkey
|
|
3
|
+
* (unauthenticated, exposes the daemon's ed25519 pubkey +
|
|
4
|
+
* fingerprint per DESIGN §"TOFU hostkey discovery") and verify the
|
|
5
|
+
* response shape.
|
|
6
|
+
*
|
|
7
|
+
* The fingerprint is what the operator should verify out-of-band
|
|
8
|
+
* before pinning (Path B / C in DESIGN). This module is the
|
|
9
|
+
* transport layer; the init command wraps it with the prompt UX.
|
|
10
|
+
*/
|
|
11
|
+
/** Wire shape returned by GET /v1/hostkey. */
|
|
12
|
+
export interface HostkeyInfo {
|
|
13
|
+
algorithm: "ed25519";
|
|
14
|
+
publicKey: string;
|
|
15
|
+
fingerprint: string;
|
|
16
|
+
}
|
|
17
|
+
/** Thrown when the daemon's response is malformed. */
|
|
18
|
+
export declare class HostkeyResponseError extends Error {
|
|
19
|
+
constructor(message: string);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fetch the host key from a creekd daemon. addr may be a bare
|
|
23
|
+
* host:port (the function adds the `http://` scheme) or a full URL.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fetchHostkey(addr: string, fetchImpl?: typeof fetch): Promise<HostkeyInfo>;
|
|
26
|
+
/** Throws if body is missing fields or has wrong types. */
|
|
27
|
+
export declare function validateHostkey(body: Partial<HostkeyInfo>): HostkeyInfo;
|
|
28
|
+
/** Compute sha256(base64-decoded publicKey) in "sha256:<hex>" form. */
|
|
29
|
+
export declare function computeFingerprint(publicKeyBase64: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Validate a Path C fingerprint string operator-pasted from the
|
|
32
|
+
* provider console / paper bundle. Accepts the canonical form
|
|
33
|
+
* "sha256:<64 hex chars>". Returns the canonical lowercased form
|
|
34
|
+
* or throws.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parsePastedFingerprint(input: string): string;
|
|
37
|
+
/** Add http:// if scheme is missing. Reject https for 0.0.x (admin is loopback / Caddy-fronted; daemon itself speaks plain HTTP). */
|
|
38
|
+
export declare function normalizeAdminAddr(addr: string): string;
|
|
39
|
+
//# sourceMappingURL=hostkey.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOFU hostkey discovery — call creekd's GET /v1/hostkey
|
|
3
|
+
* (unauthenticated, exposes the daemon's ed25519 pubkey +
|
|
4
|
+
* fingerprint per DESIGN §"TOFU hostkey discovery") and verify the
|
|
5
|
+
* response shape.
|
|
6
|
+
*
|
|
7
|
+
* The fingerprint is what the operator should verify out-of-band
|
|
8
|
+
* before pinning (Path B / C in DESIGN). This module is the
|
|
9
|
+
* transport layer; the init command wraps it with the prompt UX.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
/** Thrown when the daemon's response is malformed. */
|
|
13
|
+
export class HostkeyResponseError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(`hostkey: ${message}`);
|
|
16
|
+
this.name = "HostkeyResponseError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Fetch the host key from a creekd daemon. addr may be a bare
|
|
21
|
+
* host:port (the function adds the `http://` scheme) or a full URL.
|
|
22
|
+
*/
|
|
23
|
+
export async function fetchHostkey(addr, fetchImpl = fetch) {
|
|
24
|
+
const url = normalizeAdminAddr(addr) + "/v1/hostkey";
|
|
25
|
+
const resp = await fetchImpl(url);
|
|
26
|
+
if (!resp.ok) {
|
|
27
|
+
if (resp.status === 503) {
|
|
28
|
+
throw new HostkeyResponseError(`daemon at ${addr} returned 503 — hostkey not yet initialised`);
|
|
29
|
+
}
|
|
30
|
+
throw new HostkeyResponseError(`daemon at ${addr} returned ${resp.status}`);
|
|
31
|
+
}
|
|
32
|
+
const body = (await resp.json());
|
|
33
|
+
return validateHostkey(body);
|
|
34
|
+
}
|
|
35
|
+
/** Throws if body is missing fields or has wrong types. */
|
|
36
|
+
export function validateHostkey(body) {
|
|
37
|
+
if (body.algorithm !== "ed25519") {
|
|
38
|
+
throw new HostkeyResponseError(`unknown algorithm "${String(body.algorithm)}"`);
|
|
39
|
+
}
|
|
40
|
+
if (typeof body.publicKey !== "string" || body.publicKey.length === 0) {
|
|
41
|
+
throw new HostkeyResponseError("missing publicKey");
|
|
42
|
+
}
|
|
43
|
+
if (typeof body.fingerprint !== "string" || !body.fingerprint.startsWith("sha256:")) {
|
|
44
|
+
throw new HostkeyResponseError(`malformed fingerprint "${String(body.fingerprint)}"`);
|
|
45
|
+
}
|
|
46
|
+
// Independent fingerprint check — recompute from publicKey, compare
|
|
47
|
+
// to what the daemon claims. Prevents the daemon (or a MITM) from
|
|
48
|
+
// claiming a fingerprint that doesn't match the bytes it just
|
|
49
|
+
// returned. If THIS fails, the daemon is buggy or actively
|
|
50
|
+
// adversarial; either way we should not pin.
|
|
51
|
+
const recomputed = computeFingerprint(body.publicKey);
|
|
52
|
+
if (recomputed !== body.fingerprint) {
|
|
53
|
+
throw new HostkeyResponseError(`fingerprint ${body.fingerprint} does not match sha256(publicKey) ${recomputed}`);
|
|
54
|
+
}
|
|
55
|
+
return body;
|
|
56
|
+
}
|
|
57
|
+
/** Compute sha256(base64-decoded publicKey) in "sha256:<hex>" form. */
|
|
58
|
+
export function computeFingerprint(publicKeyBase64) {
|
|
59
|
+
const bytes = Buffer.from(publicKeyBase64, "base64");
|
|
60
|
+
const hex = createHash("sha256").update(bytes).digest("hex");
|
|
61
|
+
return `sha256:${hex}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate a Path C fingerprint string operator-pasted from the
|
|
65
|
+
* provider console / paper bundle. Accepts the canonical form
|
|
66
|
+
* "sha256:<64 hex chars>". Returns the canonical lowercased form
|
|
67
|
+
* or throws.
|
|
68
|
+
*/
|
|
69
|
+
export function parsePastedFingerprint(input) {
|
|
70
|
+
const trimmed = input.trim();
|
|
71
|
+
const match = /^sha256:([0-9a-fA-F]{64})$/.exec(trimmed);
|
|
72
|
+
if (!match) {
|
|
73
|
+
throw new HostkeyResponseError(`paste must be "sha256:<64 hex chars>"; got "${trimmed.slice(0, 40)}${trimmed.length > 40 ? "…" : ""}"`);
|
|
74
|
+
}
|
|
75
|
+
return `sha256:${match[1].toLowerCase()}`;
|
|
76
|
+
}
|
|
77
|
+
/** Add http:// if scheme is missing. Reject https for 0.0.x (admin is loopback / Caddy-fronted; daemon itself speaks plain HTTP). */
|
|
78
|
+
export function normalizeAdminAddr(addr) {
|
|
79
|
+
if (addr.startsWith("http://") || addr.startsWith("https://")) {
|
|
80
|
+
return addr.replace(/\/+$/, "");
|
|
81
|
+
}
|
|
82
|
+
return "http://" + addr.replace(/\/+$/, "");
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=hostkey.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Laptop-side multi-host registry. Schema per DESIGN-self-host-state.md
|
|
3
|
+
* §"Multi-host CLI state":
|
|
4
|
+
*
|
|
5
|
+
* ~/.creek/hosts.json
|
|
6
|
+
* {
|
|
7
|
+
* "schemaVersion": 1,
|
|
8
|
+
* "fleetLabel": "...",
|
|
9
|
+
* "hosts": [{ name, id, ip, creekdPubkey, ... }]
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* 0.0.x ships only the fields needed for TOFU hostkey pinning
|
|
13
|
+
* (name, addr, creekdPubkey + fingerprint, lastSeen). The optional
|
|
14
|
+
* agePubkey / provider / sshKeyFingerprint / region fields land in
|
|
15
|
+
* 0.1.0 when the recovery kit + capstan provisioning paths arrive.
|
|
16
|
+
*
|
|
17
|
+
* Writes are atomic via tmp + rename (DESIGN line 550). flock is
|
|
18
|
+
* deferred — 0.0.x is single-user dogfood; concurrent writers from
|
|
19
|
+
* the same laptop are not yet a concern.
|
|
20
|
+
*/
|
|
21
|
+
export declare const HOSTS_SCHEMA_VERSION = 1;
|
|
22
|
+
/** One pinned creekd host entry. */
|
|
23
|
+
export interface HostEntry {
|
|
24
|
+
/** Operator-chosen short name. Unique within the file. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** addr is host:port or just host. Where the admin API listens. */
|
|
27
|
+
addr: string;
|
|
28
|
+
/** ed25519 public key bytes, base64-encoded. From GET /v1/hostkey. */
|
|
29
|
+
creekdPubkey: string;
|
|
30
|
+
/** "sha256:<hex>" of the pubkey bytes — what the operator pastes / verifies. */
|
|
31
|
+
fingerprint: string;
|
|
32
|
+
/** RFC3339 timestamp of last successful contact. Updated on each verify. */
|
|
33
|
+
lastSeen: string;
|
|
34
|
+
/** Optional 0.0.x — populated in 0.1.0 by the recovery-kit flow. */
|
|
35
|
+
agePubkey?: string;
|
|
36
|
+
}
|
|
37
|
+
/** Top-level shape of ~/.creek/hosts.json. */
|
|
38
|
+
export interface HostsFile {
|
|
39
|
+
schemaVersion: number;
|
|
40
|
+
/** Operator-chosen label for the whole fleet. Optional. */
|
|
41
|
+
fleetLabel?: string;
|
|
42
|
+
hosts: HostEntry[];
|
|
43
|
+
}
|
|
44
|
+
/** Resolve ~/.creek/hosts.json. Exposed so tests can override via env. */
|
|
45
|
+
export declare function hostsPath(): string;
|
|
46
|
+
/** Read hosts.json. Returns an empty file shape if absent. */
|
|
47
|
+
export declare function readHosts(path?: string): HostsFile;
|
|
48
|
+
/**
|
|
49
|
+
* Write hosts.json atomically — write to <path>.tmp, then rename
|
|
50
|
+
* over the destination. The rename is atomic on POSIX filesystems;
|
|
51
|
+
* a reader sees either the old file or the new file, never a
|
|
52
|
+
* half-written one. Crash during write leaves the tmp behind but
|
|
53
|
+
* never corrupts hosts.json itself.
|
|
54
|
+
*/
|
|
55
|
+
export declare function writeHosts(file: HostsFile, path?: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* Insert or replace a host by name. Returns a new HostsFile —
|
|
58
|
+
* does not mutate the input. Pure so it's trivially testable.
|
|
59
|
+
*
|
|
60
|
+
* Replacement semantics: same `name` overwrites. Same fingerprint
|
|
61
|
+
* with a different name is allowed (operator may legitimately
|
|
62
|
+
* register the same host under two labels). Re-pinning with a
|
|
63
|
+
* NEW fingerprint for an existing name is a TOFU rotation — the
|
|
64
|
+
* caller is responsible for confirming this is intentional; the
|
|
65
|
+
* util just records it.
|
|
66
|
+
*/
|
|
67
|
+
export declare function upsertHost(file: HostsFile, entry: HostEntry): HostsFile;
|
|
68
|
+
/** Find a host by name. */
|
|
69
|
+
export declare function findHost(file: HostsFile, name: string): HostEntry | undefined;
|
|
70
|
+
//# sourceMappingURL=hosts.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Laptop-side multi-host registry. Schema per DESIGN-self-host-state.md
|
|
3
|
+
* §"Multi-host CLI state":
|
|
4
|
+
*
|
|
5
|
+
* ~/.creek/hosts.json
|
|
6
|
+
* {
|
|
7
|
+
* "schemaVersion": 1,
|
|
8
|
+
* "fleetLabel": "...",
|
|
9
|
+
* "hosts": [{ name, id, ip, creekdPubkey, ... }]
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* 0.0.x ships only the fields needed for TOFU hostkey pinning
|
|
13
|
+
* (name, addr, creekdPubkey + fingerprint, lastSeen). The optional
|
|
14
|
+
* agePubkey / provider / sshKeyFingerprint / region fields land in
|
|
15
|
+
* 0.1.0 when the recovery kit + capstan provisioning paths arrive.
|
|
16
|
+
*
|
|
17
|
+
* Writes are atomic via tmp + rename (DESIGN line 550). flock is
|
|
18
|
+
* deferred — 0.0.x is single-user dogfood; concurrent writers from
|
|
19
|
+
* the same laptop are not yet a concern.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
export const HOSTS_SCHEMA_VERSION = 1;
|
|
25
|
+
/** Resolve ~/.creek/hosts.json. Exposed so tests can override via env. */
|
|
26
|
+
export function hostsPath() {
|
|
27
|
+
return process.env.CREEK_HOSTS_PATH ?? join(homedir(), ".creek", "hosts.json");
|
|
28
|
+
}
|
|
29
|
+
/** Read hosts.json. Returns an empty file shape if absent. */
|
|
30
|
+
export function readHosts(path = hostsPath()) {
|
|
31
|
+
if (!existsSync(path)) {
|
|
32
|
+
return { schemaVersion: HOSTS_SCHEMA_VERSION, hosts: [] };
|
|
33
|
+
}
|
|
34
|
+
const raw = readFileSync(path, "utf-8");
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (typeof parsed.schemaVersion !== "number") {
|
|
37
|
+
throw new Error(`hosts.json: missing schemaVersion`);
|
|
38
|
+
}
|
|
39
|
+
if (parsed.schemaVersion !== HOSTS_SCHEMA_VERSION) {
|
|
40
|
+
throw new Error(`hosts.json: unsupported schemaVersion ${parsed.schemaVersion} ` +
|
|
41
|
+
`(want ${HOSTS_SCHEMA_VERSION})`);
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(parsed.hosts)) {
|
|
44
|
+
throw new Error(`hosts.json: hosts is not an array`);
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Write hosts.json atomically — write to <path>.tmp, then rename
|
|
50
|
+
* over the destination. The rename is atomic on POSIX filesystems;
|
|
51
|
+
* a reader sees either the old file or the new file, never a
|
|
52
|
+
* half-written one. Crash during write leaves the tmp behind but
|
|
53
|
+
* never corrupts hosts.json itself.
|
|
54
|
+
*/
|
|
55
|
+
export function writeHosts(file, path = hostsPath()) {
|
|
56
|
+
if (file.schemaVersion !== HOSTS_SCHEMA_VERSION) {
|
|
57
|
+
throw new Error(`writeHosts: schemaVersion ${file.schemaVersion} != ${HOSTS_SCHEMA_VERSION}`);
|
|
58
|
+
}
|
|
59
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
60
|
+
const tmp = path + ".tmp";
|
|
61
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", { mode: 0o600 });
|
|
62
|
+
renameSync(tmp, path);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Insert or replace a host by name. Returns a new HostsFile —
|
|
66
|
+
* does not mutate the input. Pure so it's trivially testable.
|
|
67
|
+
*
|
|
68
|
+
* Replacement semantics: same `name` overwrites. Same fingerprint
|
|
69
|
+
* with a different name is allowed (operator may legitimately
|
|
70
|
+
* register the same host under two labels). Re-pinning with a
|
|
71
|
+
* NEW fingerprint for an existing name is a TOFU rotation — the
|
|
72
|
+
* caller is responsible for confirming this is intentional; the
|
|
73
|
+
* util just records it.
|
|
74
|
+
*/
|
|
75
|
+
export function upsertHost(file, entry) {
|
|
76
|
+
const idx = file.hosts.findIndex((h) => h.name === entry.name);
|
|
77
|
+
const next = file.hosts.slice();
|
|
78
|
+
if (idx >= 0) {
|
|
79
|
+
next[idx] = entry;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
next.push(entry);
|
|
83
|
+
}
|
|
84
|
+
return { ...file, hosts: next };
|
|
85
|
+
}
|
|
86
|
+
/** Find a host by name. */
|
|
87
|
+
export function findHost(file, name) {
|
|
88
|
+
return file.hosts.find((h) => h.name === name);
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=hosts.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-local cache of the laptop's view of creekd's state.
|
|
3
|
+
*
|
|
4
|
+
* Lives at `<project>/.creek/local.json` and is the canonical
|
|
5
|
+
* source for the `If-Match` header per DESIGN-self-host-state.md
|
|
6
|
+
* §"First-party CLI MUST send If-Match" (line 230-232):
|
|
7
|
+
*
|
|
8
|
+
* "The creek CLI ... MUST send If-Match on every mutating call,
|
|
9
|
+
* sourced from .creek/local.json.lastDeploy.resourceVersion."
|
|
10
|
+
*
|
|
11
|
+
* Schema is intentionally narrow in 0.0.x:
|
|
12
|
+
* {
|
|
13
|
+
* "schemaVersion": 1,
|
|
14
|
+
* "lastDeploy": {
|
|
15
|
+
* "appId": "...",
|
|
16
|
+
* "host": "...", // hosts.json name; "" for default loopback
|
|
17
|
+
* "resourceVersion": "<opaque>",
|
|
18
|
+
* "generation": <int>,
|
|
19
|
+
* "at": "RFC3339"
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Writes are atomic (tmp + rename) to match the hosts.json
|
|
24
|
+
* convention; corrupt-then-crash leaves the file untouched.
|
|
25
|
+
*
|
|
26
|
+
* NOTE: this is per-project state, NOT per-host. Multi-host
|
|
27
|
+
* deploys of the same project use a single lastDeploy slot —
|
|
28
|
+
* switching --host between deploys invalidates the cache and
|
|
29
|
+
* triggers a fresh GET to re-seed. This is intentional: the cache
|
|
30
|
+
* is a hot path optimisation, not authoritative — the daemon's
|
|
31
|
+
* current rv is always the truth.
|
|
32
|
+
*/
|
|
33
|
+
export declare const LOCAL_SCHEMA_VERSION = 1;
|
|
34
|
+
/** Snapshot of the last successful spec-mutation against creekd. */
|
|
35
|
+
export interface LastDeploy {
|
|
36
|
+
appId: string;
|
|
37
|
+
/** hosts.json `name` if any was used; empty string for default localhost. */
|
|
38
|
+
host: string;
|
|
39
|
+
/** Opaque rv string from the wire — clients MUST NOT do arithmetic on it. */
|
|
40
|
+
resourceVersion: string;
|
|
41
|
+
/** Generation as of last successful deploy (sanity check). */
|
|
42
|
+
generation: number;
|
|
43
|
+
/** RFC3339 timestamp of the write. */
|
|
44
|
+
at: string;
|
|
45
|
+
}
|
|
46
|
+
export interface LocalCacheFile {
|
|
47
|
+
schemaVersion: number;
|
|
48
|
+
lastDeploy?: LastDeploy;
|
|
49
|
+
}
|
|
50
|
+
/** Resolve <project>/.creek/local.json. */
|
|
51
|
+
export declare function localCachePath(projectRoot: string): string;
|
|
52
|
+
/** Read the cache. Returns an empty file shape if absent. */
|
|
53
|
+
export declare function readLocalCache(projectRoot: string): LocalCacheFile;
|
|
54
|
+
/** Atomic write — tmp + rename. */
|
|
55
|
+
export declare function writeLocalCache(projectRoot: string, file: LocalCacheFile): void;
|
|
56
|
+
/**
|
|
57
|
+
* Update lastDeploy after a successful spec mutation. Reads the
|
|
58
|
+
* existing file, replaces lastDeploy, writes back. Convenience
|
|
59
|
+
* wrapper for the most common write pattern.
|
|
60
|
+
*/
|
|
61
|
+
export declare function recordLastDeploy(projectRoot: string, lastDeploy: LastDeploy): void;
|
|
62
|
+
/**
|
|
63
|
+
* Read the cached rv for an If-Match header. Returns undefined
|
|
64
|
+
* when the cache is empty, when the appId/host don't match
|
|
65
|
+
* (different project or host than last deploy), or when the cache
|
|
66
|
+
* is corrupt. Callers fall back to a fresh GET on undefined.
|
|
67
|
+
*/
|
|
68
|
+
export declare function cachedResourceVersion(projectRoot: string, appId: string, host: string): string | undefined;
|
|
69
|
+
//# sourceMappingURL=local-cache.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-local cache of the laptop's view of creekd's state.
|
|
3
|
+
*
|
|
4
|
+
* Lives at `<project>/.creek/local.json` and is the canonical
|
|
5
|
+
* source for the `If-Match` header per DESIGN-self-host-state.md
|
|
6
|
+
* §"First-party CLI MUST send If-Match" (line 230-232):
|
|
7
|
+
*
|
|
8
|
+
* "The creek CLI ... MUST send If-Match on every mutating call,
|
|
9
|
+
* sourced from .creek/local.json.lastDeploy.resourceVersion."
|
|
10
|
+
*
|
|
11
|
+
* Schema is intentionally narrow in 0.0.x:
|
|
12
|
+
* {
|
|
13
|
+
* "schemaVersion": 1,
|
|
14
|
+
* "lastDeploy": {
|
|
15
|
+
* "appId": "...",
|
|
16
|
+
* "host": "...", // hosts.json name; "" for default loopback
|
|
17
|
+
* "resourceVersion": "<opaque>",
|
|
18
|
+
* "generation": <int>,
|
|
19
|
+
* "at": "RFC3339"
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Writes are atomic (tmp + rename) to match the hosts.json
|
|
24
|
+
* convention; corrupt-then-crash leaves the file untouched.
|
|
25
|
+
*
|
|
26
|
+
* NOTE: this is per-project state, NOT per-host. Multi-host
|
|
27
|
+
* deploys of the same project use a single lastDeploy slot —
|
|
28
|
+
* switching --host between deploys invalidates the cache and
|
|
29
|
+
* triggers a fresh GET to re-seed. This is intentional: the cache
|
|
30
|
+
* is a hot path optimisation, not authoritative — the daemon's
|
|
31
|
+
* current rv is always the truth.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
export const LOCAL_SCHEMA_VERSION = 1;
|
|
36
|
+
/** Resolve <project>/.creek/local.json. */
|
|
37
|
+
export function localCachePath(projectRoot) {
|
|
38
|
+
return join(projectRoot, ".creek", "local.json");
|
|
39
|
+
}
|
|
40
|
+
/** Read the cache. Returns an empty file shape if absent. */
|
|
41
|
+
export function readLocalCache(projectRoot) {
|
|
42
|
+
const path = localCachePath(projectRoot);
|
|
43
|
+
if (!existsSync(path)) {
|
|
44
|
+
return { schemaVersion: LOCAL_SCHEMA_VERSION };
|
|
45
|
+
}
|
|
46
|
+
const raw = readFileSync(path, "utf-8");
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (typeof parsed.schemaVersion !== "number") {
|
|
49
|
+
throw new Error(`local.json: missing schemaVersion`);
|
|
50
|
+
}
|
|
51
|
+
if (parsed.schemaVersion !== LOCAL_SCHEMA_VERSION) {
|
|
52
|
+
throw new Error(`local.json: unsupported schemaVersion ${parsed.schemaVersion} ` +
|
|
53
|
+
`(want ${LOCAL_SCHEMA_VERSION})`);
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
/** Atomic write — tmp + rename. */
|
|
58
|
+
export function writeLocalCache(projectRoot, file) {
|
|
59
|
+
if (file.schemaVersion !== LOCAL_SCHEMA_VERSION) {
|
|
60
|
+
throw new Error(`writeLocalCache: schemaVersion ${file.schemaVersion} != ${LOCAL_SCHEMA_VERSION}`);
|
|
61
|
+
}
|
|
62
|
+
const path = localCachePath(projectRoot);
|
|
63
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
64
|
+
const tmp = path + ".tmp";
|
|
65
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", { mode: 0o600 });
|
|
66
|
+
renameSync(tmp, path);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Update lastDeploy after a successful spec mutation. Reads the
|
|
70
|
+
* existing file, replaces lastDeploy, writes back. Convenience
|
|
71
|
+
* wrapper for the most common write pattern.
|
|
72
|
+
*/
|
|
73
|
+
export function recordLastDeploy(projectRoot, lastDeploy) {
|
|
74
|
+
const file = readLocalCache(projectRoot);
|
|
75
|
+
writeLocalCache(projectRoot, { ...file, lastDeploy });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read the cached rv for an If-Match header. Returns undefined
|
|
79
|
+
* when the cache is empty, when the appId/host don't match
|
|
80
|
+
* (different project or host than last deploy), or when the cache
|
|
81
|
+
* is corrupt. Callers fall back to a fresh GET on undefined.
|
|
82
|
+
*/
|
|
83
|
+
export function cachedResourceVersion(projectRoot, appId, host) {
|
|
84
|
+
let file;
|
|
85
|
+
try {
|
|
86
|
+
file = readLocalCache(projectRoot);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const last = file.lastDeploy;
|
|
92
|
+
if (!last)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (last.appId !== appId)
|
|
95
|
+
return undefined;
|
|
96
|
+
if (last.host !== host)
|
|
97
|
+
return undefined;
|
|
98
|
+
return last.resourceVersion;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=local-cache.js.map
|
package/dist/utils/nextjs.d.ts
CHANGED
|
@@ -15,8 +15,10 @@ export declare function getNextVersion(cwd: string): string | null;
|
|
|
15
15
|
/**
|
|
16
16
|
* Unified Next.js build entry point.
|
|
17
17
|
*
|
|
18
|
-
* - Next.js >= 16.2.3: Creek adapter path (recommended)
|
|
19
|
-
*
|
|
18
|
+
* - Next.js >= 16.2.3: Creek adapter path (recommended). The adapter is
|
|
19
|
+
* lazily installed into .creek/node_modules on first use — the CLI never
|
|
20
|
+
* depends on it directly, so non-Next.js users never pay for it.
|
|
21
|
+
* - Next.js < 16.2.3 (or adapter install fails): legacy opennext path.
|
|
20
22
|
*
|
|
21
23
|
* Min version for the adapter path matches @solcreek/adapter-creek's
|
|
22
24
|
* peerDependency, which pins Next.js >= 16.2.3 to fix CVE-2026-23869.
|