@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
package/dist/commands/init.js
CHANGED
|
@@ -5,21 +5,39 @@ import { join, basename } from "node:path";
|
|
|
5
5
|
import { stringify } from "smol-toml";
|
|
6
6
|
import { detectFramework } from "@solcreek/sdk";
|
|
7
7
|
import { globalArgs, resolveJsonMode, jsonOutput, shouldAutoConfirm } from "../utils/output.js";
|
|
8
|
+
import { readHosts, writeHosts, upsertHost, HOSTS_SCHEMA_VERSION } from "../utils/hosts.js";
|
|
9
|
+
import { fetchHostkey, parsePastedFingerprint, HostkeyResponseError, } from "../utils/hostkey.js";
|
|
10
|
+
import { ensureGitignoreEntries } from "../utils/gitignore.js";
|
|
8
11
|
export const initCommand = defineCommand({
|
|
9
12
|
meta: {
|
|
10
13
|
name: "init",
|
|
11
|
-
description: "Initialize a new Creek project",
|
|
14
|
+
description: "Initialize a new Creek project (or register a self-host creekd via --adopt / --hostkey-fingerprint)",
|
|
12
15
|
},
|
|
13
16
|
args: {
|
|
14
17
|
name: {
|
|
15
18
|
type: "string",
|
|
16
|
-
description: "Project name",
|
|
19
|
+
description: "Project name (project init) OR host short-name (self-host init)",
|
|
20
|
+
required: false,
|
|
21
|
+
},
|
|
22
|
+
adopt: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "TOFU-pin a creekd host at <addr> (Path B). Fetches GET /v1/hostkey, prompts to verify, writes ~/.creek/hosts.json.",
|
|
25
|
+
required: false,
|
|
26
|
+
},
|
|
27
|
+
"hostkey-fingerprint": {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "Out-of-band fingerprint paste (Path C). \"sha256:<hex>\" — pasted from provider console or paper bundle. Requires --adopt for the addr.",
|
|
17
30
|
required: false,
|
|
18
31
|
},
|
|
19
32
|
...globalArgs,
|
|
20
33
|
},
|
|
21
34
|
async run({ args }) {
|
|
22
35
|
const jsonMode = resolveJsonMode(args);
|
|
36
|
+
// Self-host registration path — DESIGN §"TOFU hostkey discovery".
|
|
37
|
+
// Diverges from project-init entirely; mutually exclusive paths.
|
|
38
|
+
if (args.adopt || args["hostkey-fingerprint"]) {
|
|
39
|
+
return await initHostAdopt(args.adopt, args["hostkey-fingerprint"], args.name, jsonMode, shouldAutoConfirm(args));
|
|
40
|
+
}
|
|
23
41
|
const cwd = process.cwd();
|
|
24
42
|
const configPath = join(cwd, "creek.toml");
|
|
25
43
|
if (existsSync(configPath)) {
|
|
@@ -62,6 +80,7 @@ export const initCommand = defineCommand({
|
|
|
62
80
|
...(useDb ? { resources: { database: true } } : {}),
|
|
63
81
|
};
|
|
64
82
|
writeFileSync(configPath, stringify(config));
|
|
83
|
+
ensureGitignoreEntries(cwd);
|
|
65
84
|
// Scaffold worker + d1-schema example when database enabled
|
|
66
85
|
if (useDb) {
|
|
67
86
|
const workerDir = join(cwd, "worker");
|
|
@@ -122,4 +141,141 @@ export default app;
|
|
|
122
141
|
}
|
|
123
142
|
},
|
|
124
143
|
});
|
|
144
|
+
/**
|
|
145
|
+
* Self-host adopt flow per DESIGN §"TOFU hostkey discovery".
|
|
146
|
+
*
|
|
147
|
+
* Two paths:
|
|
148
|
+
*
|
|
149
|
+
* Path B: --adopt=<addr> [--name <name>]
|
|
150
|
+
* Fetches GET /v1/hostkey, recomputes the fingerprint from the
|
|
151
|
+
* returned publicKey, prompts the operator to verify against
|
|
152
|
+
* the provider console / paper bundle, and writes the pinned
|
|
153
|
+
* entry to ~/.creek/hosts.json. Operator MUST visually
|
|
154
|
+
* confirm — MITM on first contact is otherwise undetectable.
|
|
155
|
+
*
|
|
156
|
+
* Path C: --hostkey-fingerprint=<sha256:...> --adopt=<addr>
|
|
157
|
+
* The operator has the fingerprint from out-of-band (provider
|
|
158
|
+
* console, paper bundle, etc.). We still fetch GET /v1/hostkey
|
|
159
|
+
* to capture the publicKey bytes + verify the daemon
|
|
160
|
+
* self-reports the same fingerprint, but the trust comes from
|
|
161
|
+
* the operator's paste, not from the wire.
|
|
162
|
+
*
|
|
163
|
+
* Path A (capstan-provisioned) is deferred — needs capstan
|
|
164
|
+
* integration.
|
|
165
|
+
*/
|
|
166
|
+
async function initHostAdopt(addr, pastedFingerprint, hostName, jsonMode, autoConfirm) {
|
|
167
|
+
if (!addr) {
|
|
168
|
+
const msg = "--hostkey-fingerprint requires --adopt=<addr> for the daemon to talk to";
|
|
169
|
+
if (jsonMode)
|
|
170
|
+
jsonOutput({ ok: false, error: "missing_addr", message: msg }, 1, []);
|
|
171
|
+
consola.error(msg);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const name = hostName ?? defaultHostName(addr);
|
|
175
|
+
// Path C — validate the pasted fingerprint shape BEFORE talking
|
|
176
|
+
// to the network. If the paste is malformed there's no point
|
|
177
|
+
// continuing.
|
|
178
|
+
let expectFingerprint;
|
|
179
|
+
if (pastedFingerprint) {
|
|
180
|
+
try {
|
|
181
|
+
expectFingerprint = parsePastedFingerprint(pastedFingerprint);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
185
|
+
if (jsonMode)
|
|
186
|
+
jsonOutput({ ok: false, error: "bad_fingerprint_paste", message: msg }, 1, []);
|
|
187
|
+
consola.error(msg);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Fetch the daemon's hostkey. validateHostkey() inside
|
|
192
|
+
// fetchHostkey recomputes the fingerprint from the returned
|
|
193
|
+
// publicKey — protects against a daemon that lies about its own
|
|
194
|
+
// fingerprint.
|
|
195
|
+
let info;
|
|
196
|
+
try {
|
|
197
|
+
info = await fetchHostkey(addr);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
if (e instanceof HostkeyResponseError) {
|
|
201
|
+
if (jsonMode)
|
|
202
|
+
jsonOutput({ ok: false, error: "hostkey_fetch_failed", message: e.message }, 1, []);
|
|
203
|
+
consola.error(e.message);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
207
|
+
if (jsonMode)
|
|
208
|
+
jsonOutput({ ok: false, error: "hostkey_fetch_failed", message: msg }, 1, []);
|
|
209
|
+
consola.error(`failed to fetch hostkey from ${addr}: ${msg}`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
// Path C — the paste MUST match the wire fingerprint. Mismatch
|
|
213
|
+
// means either the wire is being MITM'd (the dangerous case) or
|
|
214
|
+
// the paste was for a different host (the human-error case).
|
|
215
|
+
// Either way refuse to pin.
|
|
216
|
+
if (expectFingerprint && expectFingerprint !== info.fingerprint) {
|
|
217
|
+
const msg = `fingerprint mismatch: pasted ${expectFingerprint}, daemon at ${addr} returned ${info.fingerprint}`;
|
|
218
|
+
if (jsonMode)
|
|
219
|
+
jsonOutput({ ok: false, error: "hostkey_fingerprint_mismatch", message: msg, expected: expectFingerprint, got: info.fingerprint }, 1, []);
|
|
220
|
+
consola.error(msg);
|
|
221
|
+
consola.info("Possible MITM on first-contact wire, or you pasted the wrong fingerprint. Verify against provider console before retrying.");
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
// Path B — no paste. Prompt the operator to verify out-of-band
|
|
225
|
+
// before pinning. Auto-confirm bypass only fires in non-TTY
|
|
226
|
+
// (CI / scripts that have already verified externally).
|
|
227
|
+
if (!expectFingerprint && !autoConfirm) {
|
|
228
|
+
consola.info(`Fingerprint from ${addr}:`);
|
|
229
|
+
consola.info(` ${info.fingerprint}`);
|
|
230
|
+
consola.info("");
|
|
231
|
+
consola.info("Verify this matches the provider console / serial output / paper bundle BEFORE confirming.");
|
|
232
|
+
const ok = (await consola.prompt("Pin this host?", { type: "confirm" }));
|
|
233
|
+
if (!ok) {
|
|
234
|
+
if (jsonMode)
|
|
235
|
+
jsonOutput({ ok: false, error: "user_aborted", message: "operator declined to pin fingerprint" }, 1, []);
|
|
236
|
+
consola.warn("Aborted — host not pinned.");
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Persist. upsertHost replaces by name; the operator can re-run
|
|
241
|
+
// with the same --adopt to refresh lastSeen.
|
|
242
|
+
const entry = {
|
|
243
|
+
name,
|
|
244
|
+
addr,
|
|
245
|
+
creekdPubkey: info.publicKey,
|
|
246
|
+
fingerprint: info.fingerprint,
|
|
247
|
+
lastSeen: new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
const file = readHosts();
|
|
250
|
+
const next = upsertHost(file, entry);
|
|
251
|
+
writeHosts(next);
|
|
252
|
+
if (jsonMode) {
|
|
253
|
+
jsonOutput({
|
|
254
|
+
ok: true,
|
|
255
|
+
name,
|
|
256
|
+
addr,
|
|
257
|
+
fingerprint: info.fingerprint,
|
|
258
|
+
path: "~/.creek/hosts.json",
|
|
259
|
+
}, 0, [
|
|
260
|
+
{ command: `creek deploy --host ${name}`, description: "Deploy to this host" },
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
consola.success(`Pinned ${name} → ${addr}`);
|
|
264
|
+
consola.info(` fingerprint: ${info.fingerprint}`);
|
|
265
|
+
console.log("");
|
|
266
|
+
consola.info(" Next steps:");
|
|
267
|
+
consola.info(` creek deploy --host ${name} Deploy to this host`);
|
|
268
|
+
void HOSTS_SCHEMA_VERSION; // re-exported for tests; silence "imported but unused" guards in some setups
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Derive a short host name from the adopt address. e.g.
|
|
272
|
+
* --adopt=5.75.231.44:9080 → "h-5-75-231-44"
|
|
273
|
+
* --adopt=my.host.dev → "h-my-host-dev"
|
|
274
|
+
* Operator may override via --name.
|
|
275
|
+
*/
|
|
276
|
+
function defaultHostName(addr) {
|
|
277
|
+
const noScheme = addr.replace(/^https?:\/\//, "").replace(/[:/].*$/, "");
|
|
278
|
+
const safe = noScheme.replace(/[^a-z0-9]/gi, "-").toLowerCase();
|
|
279
|
+
return `h-${safe}`;
|
|
280
|
+
}
|
|
125
281
|
//# sourceMappingURL=init.js.map
|
package/dist/commands/logs.d.ts
CHANGED
|
@@ -71,5 +71,17 @@ export declare const logsCommand: import("citty").CommandDef<{
|
|
|
71
71
|
type: "boolean";
|
|
72
72
|
description: string;
|
|
73
73
|
};
|
|
74
|
+
server: {
|
|
75
|
+
type: "string";
|
|
76
|
+
description: string;
|
|
77
|
+
};
|
|
78
|
+
"creekd-token": {
|
|
79
|
+
type: "string";
|
|
80
|
+
description: string;
|
|
81
|
+
};
|
|
82
|
+
tail: {
|
|
83
|
+
type: "string";
|
|
84
|
+
description: string;
|
|
85
|
+
};
|
|
74
86
|
}>;
|
|
75
87
|
//# sourceMappingURL=logs.d.ts.map
|
package/dist/commands/logs.js
CHANGED
|
@@ -5,6 +5,7 @@ import { CreekClient, resolveConfig, ConfigNotFoundError, } from "@solcreek/sdk"
|
|
|
5
5
|
import { getToken, getApiUrl } from "../utils/config.js";
|
|
6
6
|
import { globalArgs, resolveJsonMode, jsonOutput, AUTH_BREADCRUMBS, NO_PROJECT_BREADCRUMBS, } from "../utils/output.js";
|
|
7
7
|
import { matchesClientSide, describeFilters, safeStringify as ssExport } from "./logs-filter.js";
|
|
8
|
+
import { CreekdClient, CreekdApiError, getCreekdUrl } from "../utils/creekd-client.js";
|
|
8
9
|
/**
|
|
9
10
|
* `creek logs` — read structured tenant logs from R2 archive.
|
|
10
11
|
*
|
|
@@ -26,7 +27,7 @@ import { matchesClientSide, describeFilters, safeStringify as ssExport } from ".
|
|
|
26
27
|
export const logsCommand = defineCommand({
|
|
27
28
|
meta: {
|
|
28
29
|
name: "logs",
|
|
29
|
-
description: "Read recent log entries for a project",
|
|
30
|
+
description: "Read recent log entries for a project. Only worker invocations appear — edge-cached requests don't invoke the worker and won't show here. Use `creek metrics` for total traffic including cache hits.",
|
|
30
31
|
},
|
|
31
32
|
args: {
|
|
32
33
|
project: {
|
|
@@ -73,10 +74,25 @@ export const logsCommand = defineCommand({
|
|
|
73
74
|
type: "boolean",
|
|
74
75
|
description: "Live tail via WebSocket. Prints recent context first, then streams new entries until Ctrl+C.",
|
|
75
76
|
},
|
|
77
|
+
server: {
|
|
78
|
+
type: "string",
|
|
79
|
+
description: "creekd admin API URL — routes to creekd log tail instead of control-plane (or $CREEKD_URL)",
|
|
80
|
+
},
|
|
81
|
+
"creekd-token": {
|
|
82
|
+
type: "string",
|
|
83
|
+
description: "Bearer token for creekd (or $CREEKD_TOKEN)",
|
|
84
|
+
},
|
|
85
|
+
tail: {
|
|
86
|
+
type: "string",
|
|
87
|
+
description: "Number of lines (creekd mode only, default 100)",
|
|
88
|
+
},
|
|
76
89
|
...globalArgs,
|
|
77
90
|
},
|
|
78
91
|
async run({ args }) {
|
|
79
92
|
const jsonMode = resolveJsonMode(args);
|
|
93
|
+
if (args.server || process.env.CREEKD_URL) {
|
|
94
|
+
return creekdLogs(args, jsonMode);
|
|
95
|
+
}
|
|
80
96
|
const token = getToken();
|
|
81
97
|
if (!token) {
|
|
82
98
|
if (jsonMode)
|
|
@@ -269,6 +285,38 @@ async function follow(client, projectSlug, filters, initialSeenAfter, jsonMode)
|
|
|
269
285
|
backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS);
|
|
270
286
|
}
|
|
271
287
|
}
|
|
288
|
+
async function creekdLogs(args, jsonMode) {
|
|
289
|
+
const client = new CreekdClient(args.server || getCreekdUrl(), args["creekd-token"] || process.env.CREEKD_TOKEN || process.env.CREEKCTL_TOKEN || "");
|
|
290
|
+
const id = args.project || args.id;
|
|
291
|
+
if (!id) {
|
|
292
|
+
if (jsonMode)
|
|
293
|
+
jsonOutput({ ok: false, error: "missing_id", message: "App ID required" }, 1, [
|
|
294
|
+
{ command: "creek logs --server <url> --project <id>", description: "Specify app ID" },
|
|
295
|
+
]);
|
|
296
|
+
consola.error("App ID required. Use --project <id>.");
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
const tail = args.tail ? Number(args.tail) : 100;
|
|
300
|
+
try {
|
|
301
|
+
const text = await client.getAppLogs(id, tail);
|
|
302
|
+
if (jsonMode) {
|
|
303
|
+
const lines = text.split("\n").filter(Boolean);
|
|
304
|
+
jsonOutput({ ok: true, app_id: id, lines, count: lines.length }, 0, [
|
|
305
|
+
{ command: `creek logs --server ${args.server || getCreekdUrl()} --project ${id} --follow`, description: "Stream live logs" },
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
printCreekdLogs(text);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (err instanceof CreekdApiError) {
|
|
312
|
+
if (jsonMode)
|
|
313
|
+
jsonOutput({ ok: false, error: err.code, message: err.message }, 1);
|
|
314
|
+
consola.error(err.status === 404 ? `App "${id}" not found.` : `Logs failed: ${err.message}`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
272
320
|
function sleep(ms) {
|
|
273
321
|
return new Promise((r) => setTimeout(r, ms));
|
|
274
322
|
}
|
|
@@ -355,4 +403,24 @@ function printEntry(entry) {
|
|
|
355
403
|
// safeStringify lives in logs-filter.js (re-exported for nested
|
|
356
404
|
// console.log message rendering in printEntry below).
|
|
357
405
|
const safeStringify = ssExport;
|
|
406
|
+
function printCreekdLogs(text) {
|
|
407
|
+
const lines = text.split("\n").filter(Boolean);
|
|
408
|
+
for (const line of lines) {
|
|
409
|
+
let rec;
|
|
410
|
+
try {
|
|
411
|
+
rec = JSON.parse(line);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
process.stdout.write(line + "\n");
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const ts = rec.ts
|
|
418
|
+
? color(new Date(rec.ts).toISOString().replace("T", " ").slice(0, 23), "dim")
|
|
419
|
+
: "";
|
|
420
|
+
const stream = rec.stream === "stderr"
|
|
421
|
+
? color("err", "red")
|
|
422
|
+
: color("out", "cyan");
|
|
423
|
+
process.stdout.write(`${ts} ${stream} ${rec.msg ?? ""}\n`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
358
426
|
//# sourceMappingURL=logs.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const restartCommand: import("citty").CommandDef<{
|
|
2
|
+
json: {
|
|
3
|
+
type: "boolean";
|
|
4
|
+
description: string;
|
|
5
|
+
default: boolean;
|
|
6
|
+
};
|
|
7
|
+
yes: {
|
|
8
|
+
type: "boolean";
|
|
9
|
+
description: string;
|
|
10
|
+
default: boolean;
|
|
11
|
+
};
|
|
12
|
+
id: {
|
|
13
|
+
type: "positional";
|
|
14
|
+
description: string;
|
|
15
|
+
required: true;
|
|
16
|
+
};
|
|
17
|
+
server: {
|
|
18
|
+
type: "string";
|
|
19
|
+
description: string;
|
|
20
|
+
};
|
|
21
|
+
token: {
|
|
22
|
+
type: "string";
|
|
23
|
+
description: string;
|
|
24
|
+
};
|
|
25
|
+
}>;
|
|
26
|
+
//# sourceMappingURL=restart.d.ts.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import consola from "consola";
|
|
3
|
+
import { globalArgs, resolveJsonMode, jsonOutput } from "../utils/output.js";
|
|
4
|
+
import { CreekdClient, CreekdApiError, getCreekdUrl } from "../utils/creekd-client.js";
|
|
5
|
+
export const restartCommand = defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: "restart",
|
|
8
|
+
description: "Restart an app on a creekd instance",
|
|
9
|
+
},
|
|
10
|
+
args: {
|
|
11
|
+
id: {
|
|
12
|
+
type: "positional",
|
|
13
|
+
description: "App ID to restart",
|
|
14
|
+
required: true,
|
|
15
|
+
},
|
|
16
|
+
server: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "creekd admin API URL (or $CREEKD_URL)",
|
|
19
|
+
},
|
|
20
|
+
token: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Bearer token (or $CREEKD_TOKEN)",
|
|
23
|
+
},
|
|
24
|
+
...globalArgs,
|
|
25
|
+
},
|
|
26
|
+
async run({ args }) {
|
|
27
|
+
const jsonMode = resolveJsonMode(args);
|
|
28
|
+
const client = new CreekdClient(args.server || getCreekdUrl(), args.token || process.env.CREEKD_TOKEN || process.env.CREEKCTL_TOKEN || "");
|
|
29
|
+
try {
|
|
30
|
+
const app = await client.restartApp(args.id);
|
|
31
|
+
if (jsonMode) {
|
|
32
|
+
jsonOutput({ ok: true, app }, 0, [
|
|
33
|
+
{ command: `creek logs ${app.id}`, description: "Stream app logs" },
|
|
34
|
+
{ command: `creek top`, description: "Live process overview" },
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
consola.success(`Restarted ${app.id} (pid ${app.pid})`);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err instanceof CreekdApiError) {
|
|
41
|
+
if (jsonMode)
|
|
42
|
+
jsonOutput({ ok: false, error: err.code, message: err.message }, 1);
|
|
43
|
+
if (err.status === 404) {
|
|
44
|
+
consola.error(`App "${args.id}" not found.`);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
consola.error(`Restart failed: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
//# sourceMappingURL=restart.js.map
|
|
@@ -23,5 +23,18 @@ export declare const rollbackCommand: import("citty").CommandDef<{
|
|
|
23
23
|
type: "string";
|
|
24
24
|
description: string;
|
|
25
25
|
};
|
|
26
|
+
host: {
|
|
27
|
+
type: "string";
|
|
28
|
+
description: string;
|
|
29
|
+
};
|
|
30
|
+
to: {
|
|
31
|
+
type: "string";
|
|
32
|
+
description: string;
|
|
33
|
+
};
|
|
34
|
+
"bypass-rv": {
|
|
35
|
+
type: "boolean";
|
|
36
|
+
description: string;
|
|
37
|
+
default: false;
|
|
38
|
+
};
|
|
26
39
|
}>;
|
|
27
40
|
//# sourceMappingURL=rollback.d.ts.map
|
|
@@ -5,6 +5,9 @@ import { getToken, getApiUrl } from "../utils/config.js";
|
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { globalArgs, resolveJsonMode, jsonOutput, AUTH_BREADCRUMBS } from "../utils/output.js";
|
|
8
|
+
import { CreekdClient, CreekdResourceVersionMismatchError } from "../utils/creekd-client.js";
|
|
9
|
+
import { readHosts, findHost } from "../utils/hosts.js";
|
|
10
|
+
import { cachedResourceVersion, recordLastDeploy, } from "../utils/local-cache.js";
|
|
8
11
|
function getProjectSlug(args) {
|
|
9
12
|
if (args?.project)
|
|
10
13
|
return args.project;
|
|
@@ -23,7 +26,7 @@ export const rollbackCommand = defineCommand({
|
|
|
23
26
|
args: {
|
|
24
27
|
deployment: {
|
|
25
28
|
type: "positional",
|
|
26
|
-
description: "Deployment ID to rollback to (default: previous)",
|
|
29
|
+
description: "Deployment ID to rollback to (default: previous, CF Workers target)",
|
|
27
30
|
required: false,
|
|
28
31
|
},
|
|
29
32
|
message: {
|
|
@@ -35,10 +38,37 @@ export const rollbackCommand = defineCommand({
|
|
|
35
38
|
type: "string",
|
|
36
39
|
description: "Project slug (default: from creek.toml)",
|
|
37
40
|
},
|
|
41
|
+
host: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Roll back on the named self-host creekd (from ~/.creek/hosts.json). Requires --to.",
|
|
44
|
+
},
|
|
45
|
+
to: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "Target release seq for self-host rollback (--host required).",
|
|
48
|
+
},
|
|
49
|
+
"bypass-rv": {
|
|
50
|
+
type: "boolean",
|
|
51
|
+
description: "On 412 If-Match mismatch, auto-fetch current rv and retry (self-host only).",
|
|
52
|
+
default: false,
|
|
53
|
+
},
|
|
38
54
|
...globalArgs,
|
|
39
55
|
},
|
|
40
56
|
async run({ args }) {
|
|
41
57
|
const jsonMode = resolveJsonMode(args);
|
|
58
|
+
// Self-host path — bypass the CF Workers / CreekClient flow.
|
|
59
|
+
// Mutually exclusive with the existing CF rollback semantics:
|
|
60
|
+
// --host pivots into the creekd HTTP API per
|
|
61
|
+
// DESIGN-self-host-state.md §"The Release resource".
|
|
62
|
+
if (args.host) {
|
|
63
|
+
return await rollbackSelfHost(args.host, args.to, args.project, args["bypass-rv"] === true, jsonMode);
|
|
64
|
+
}
|
|
65
|
+
if (args.to) {
|
|
66
|
+
const msg = "--to requires --host (use --deployment for CF Workers rollback)";
|
|
67
|
+
if (jsonMode)
|
|
68
|
+
jsonOutput({ ok: false, error: "missing_host", message: msg }, 1, []);
|
|
69
|
+
consola.error(msg);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
42
72
|
const token = getToken();
|
|
43
73
|
if (!token) {
|
|
44
74
|
if (jsonMode)
|
|
@@ -79,4 +109,161 @@ export const rollbackCommand = defineCommand({
|
|
|
79
109
|
}
|
|
80
110
|
},
|
|
81
111
|
});
|
|
112
|
+
/**
|
|
113
|
+
* Self-host rollback per DESIGN-self-host-state.md §"The Release
|
|
114
|
+
* resource":
|
|
115
|
+
*
|
|
116
|
+
* creek rollback --host=<name> --to=<seq>
|
|
117
|
+
*
|
|
118
|
+
* Reads the host from ~/.creek/hosts.json (must be pinned via
|
|
119
|
+
* `creek init --adopt` first), resolves If-Match from
|
|
120
|
+
* .creek/local.json (or falls back to a fresh GET), calls
|
|
121
|
+
* POST /v1/apps/{appId}/rollback?to=<seq>, and writes the new rv
|
|
122
|
+
* back to the local cache on success.
|
|
123
|
+
*
|
|
124
|
+
* 412 behaviour: emits a structured error with the daemon's
|
|
125
|
+
* current rv. Does NOT auto-retry unless --bypass-rv is set
|
|
126
|
+
* (DESIGN §"First-party CLI MUST send If-Match": "does NOT
|
|
127
|
+
* auto-retry by default").
|
|
128
|
+
*/
|
|
129
|
+
async function rollbackSelfHost(hostName, toRaw, projectArg, bypassRv, jsonMode) {
|
|
130
|
+
if (!toRaw) {
|
|
131
|
+
const msg = "self-host rollback requires --to=<releaseSeq>";
|
|
132
|
+
if (jsonMode)
|
|
133
|
+
jsonOutput({ ok: false, error: "missing_to", message: msg }, 1, []);
|
|
134
|
+
consola.error(msg);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
const toSeq = Number.parseInt(toRaw, 10);
|
|
138
|
+
if (!Number.isFinite(toSeq) || toSeq <= 0) {
|
|
139
|
+
const msg = `--to must be a positive integer (got "${toRaw}")`;
|
|
140
|
+
if (jsonMode)
|
|
141
|
+
jsonOutput({ ok: false, error: "bad_to", message: msg }, 1, []);
|
|
142
|
+
consola.error(msg);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
// Resolve host from hosts.json.
|
|
146
|
+
const hostsFile = readHosts();
|
|
147
|
+
const host = findHost(hostsFile, hostName);
|
|
148
|
+
if (!host) {
|
|
149
|
+
const msg = `host "${hostName}" not found in ~/.creek/hosts.json (run \`creek init --adopt=<addr>\` to pin it first)`;
|
|
150
|
+
if (jsonMode)
|
|
151
|
+
jsonOutput({ ok: false, error: "host_not_pinned", message: msg, host: hostName }, 1, []);
|
|
152
|
+
consola.error(msg);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
// Project name = creekd app ID.
|
|
156
|
+
const cwd = process.cwd();
|
|
157
|
+
const appId = resolveAppId(projectArg, cwd);
|
|
158
|
+
if (!appId) {
|
|
159
|
+
const msg = "no creek.toml in cwd and --project not specified";
|
|
160
|
+
if (jsonMode)
|
|
161
|
+
jsonOutput({ ok: false, error: "no_project", message: msg }, 1, []);
|
|
162
|
+
consola.error(msg);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
const client = new CreekdClient(host.addr);
|
|
166
|
+
const release = await doRollbackWithIfMatch(client, appId, toSeq, cwd, host.name, bypassRv, jsonMode);
|
|
167
|
+
// The Release wire shape doesn't carry the app's new
|
|
168
|
+
// resourceVersion directly — fetch the envelope to capture it
|
|
169
|
+
// for the local cache. Failure here doesn't roll back the
|
|
170
|
+
// rollback; we just lose cache freshness and the next mutation
|
|
171
|
+
// does a fresh GET. Log and continue.
|
|
172
|
+
try {
|
|
173
|
+
const envelope = await client.getApp(appId);
|
|
174
|
+
recordLastDeploy(cwd, {
|
|
175
|
+
appId,
|
|
176
|
+
host: host.name,
|
|
177
|
+
resourceVersion: envelope.metadata.resourceVersion,
|
|
178
|
+
generation: envelope.metadata.generation,
|
|
179
|
+
at: new Date().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
if (!jsonMode) {
|
|
184
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
185
|
+
consola.warn(`rollback succeeded but local cache refresh failed: ${msg}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (jsonMode) {
|
|
189
|
+
jsonOutput({
|
|
190
|
+
ok: true,
|
|
191
|
+
host: host.name,
|
|
192
|
+
app: appId,
|
|
193
|
+
release,
|
|
194
|
+
}, 0, [
|
|
195
|
+
{ command: `creek status --host ${host.name}`, description: "Check rollback status" },
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
consola.success(`Rolled back ${appId} on ${host.name} to release seq ${release.spec.rolledBackFrom}`);
|
|
199
|
+
consola.info(` new release seq: ${release.spec.releaseSeq} (phase=${release.phase})`);
|
|
200
|
+
if (release.spec.originalArtifactRelease && release.spec.originalArtifactRelease !== release.spec.rolledBackFrom) {
|
|
201
|
+
consola.info(` original artifact: seq ${release.spec.originalArtifactRelease}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Run the rollback POST with If-Match resolved from the local
|
|
206
|
+
* cache. On 412 mismatch, either re-fetch + retry (when
|
|
207
|
+
* --bypass-rv is set) or surface a structured error so the
|
|
208
|
+
* operator can decide.
|
|
209
|
+
*/
|
|
210
|
+
async function doRollbackWithIfMatch(client, appId, toSeq, cwd, hostName, bypassRv, jsonMode) {
|
|
211
|
+
// Read cached rv. If absent, fall back to a fresh GET — better
|
|
212
|
+
// to send a real If-Match (gets a clean 412 on drift) than to
|
|
213
|
+
// emit Warning: 299 "unconditional-write".
|
|
214
|
+
let ifMatch = cachedResourceVersion(cwd, appId, hostName);
|
|
215
|
+
if (!ifMatch) {
|
|
216
|
+
try {
|
|
217
|
+
const envelope = await client.getApp(appId);
|
|
218
|
+
ifMatch = envelope.metadata.resourceVersion;
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
// App might not exist on this host yet; let the rollback
|
|
222
|
+
// itself surface 404 release_artifact_pruned with full
|
|
223
|
+
// detail rather than guessing.
|
|
224
|
+
ifMatch = undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
return await client.rollbackApp(appId, toSeq, ifMatch ? { ifMatch } : {});
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
if (e instanceof CreekdResourceVersionMismatchError && bypassRv) {
|
|
232
|
+
// --bypass-rv: auto-refetch current rv and retry exactly
|
|
233
|
+
// once. A second 412 means concurrent writers; that's a
|
|
234
|
+
// real conflict and surfaces normally.
|
|
235
|
+
const envelope = await client.getApp(appId);
|
|
236
|
+
return await client.rollbackApp(appId, toSeq, {
|
|
237
|
+
ifMatch: envelope.metadata.resourceVersion,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (e instanceof CreekdResourceVersionMismatchError) {
|
|
241
|
+
const msg = `resource version drifted (sent ${e.attemptedResourceVersion}, current ${e.currentResourceVersion}) — re-run with --bypass-rv to auto-refresh, or refresh the local cache manually`;
|
|
242
|
+
if (jsonMode) {
|
|
243
|
+
jsonOutput({
|
|
244
|
+
ok: false,
|
|
245
|
+
error: "resource_version_mismatch",
|
|
246
|
+
message: msg,
|
|
247
|
+
attemptedResourceVersion: e.attemptedResourceVersion,
|
|
248
|
+
currentResourceVersion: e.currentResourceVersion,
|
|
249
|
+
}, 1, [
|
|
250
|
+
{ command: `creek rollback --host ${hostName} --to ${toSeq} --bypass-rv`, description: "Auto-refresh and retry" },
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
consola.error(msg);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Resolve creekd app ID — either explicit --project or
|
|
260
|
+
* parsed from creek.toml. Returns "" when neither is available. */
|
|
261
|
+
function resolveAppId(projectArg, cwd) {
|
|
262
|
+
if (projectArg)
|
|
263
|
+
return projectArg;
|
|
264
|
+
const configPath = join(cwd, "creek.toml");
|
|
265
|
+
if (!existsSync(configPath))
|
|
266
|
+
return "";
|
|
267
|
+
return parseConfig(readFileSync(configPath, "utf-8")).project.name;
|
|
268
|
+
}
|
|
82
269
|
//# sourceMappingURL=rollback.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const stopCommand: import("citty").CommandDef<{
|
|
2
|
+
json: {
|
|
3
|
+
type: "boolean";
|
|
4
|
+
description: string;
|
|
5
|
+
default: boolean;
|
|
6
|
+
};
|
|
7
|
+
yes: {
|
|
8
|
+
type: "boolean";
|
|
9
|
+
description: string;
|
|
10
|
+
default: boolean;
|
|
11
|
+
};
|
|
12
|
+
id: {
|
|
13
|
+
type: "positional";
|
|
14
|
+
description: string;
|
|
15
|
+
required: true;
|
|
16
|
+
};
|
|
17
|
+
server: {
|
|
18
|
+
type: "string";
|
|
19
|
+
description: string;
|
|
20
|
+
};
|
|
21
|
+
token: {
|
|
22
|
+
type: "string";
|
|
23
|
+
description: string;
|
|
24
|
+
};
|
|
25
|
+
}>;
|
|
26
|
+
//# sourceMappingURL=stop.d.ts.map
|