@blocofy/cli 0.14.0 → 0.16.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 +229 -15
- package/bin/blocofy.mjs +993 -53
- package/lib/args.mjs +2 -2
- package/lib/credentials.mjs +150 -6
- package/lib/dev-server.mjs +6 -3
- package/lib/init.mjs +382 -0
- package/lib/loopback.mjs +111 -0
- package/lib/media-uses.mjs +4 -2
- package/lib/oauth.mjs +295 -0
- package/lib/refresh.mjs +244 -0
- package/lib/secret-store.mjs +177 -10
- package/lib/target.mjs +25 -11
- package/lib/theme-sync.mjs +160 -5
- package/lib/theme-work.mjs +369 -0
- package/lib/validate.mjs +4 -1
- package/package.json +1 -1
package/bin/blocofy.mjs
CHANGED
|
@@ -29,7 +29,14 @@ import {
|
|
|
29
29
|
saveStore,
|
|
30
30
|
withStoreLock,
|
|
31
31
|
writeSecret,
|
|
32
|
+
credentialRefusal,
|
|
33
|
+
readOAuthTokens,
|
|
34
|
+
writeOAuthTokens,
|
|
32
35
|
} from "../lib/credentials.mjs";
|
|
36
|
+
import { CLI_CLIENT_ID, LoginError, revokeToken, runBrowserLogin } from "../lib/oauth.mjs";
|
|
37
|
+
import { accessTokenFor } from "../lib/refresh.mjs";
|
|
38
|
+
import { oauthSecretStore, storeLabel, SecretStoreError } from "../lib/secret-store.mjs";
|
|
39
|
+
import { InitError, inspectInitDir, readInitState, runInit, initApi } from "../lib/init.mjs";
|
|
33
40
|
import { printError, printTarget, printWarning, redact, registerSecret, targetData } from "../lib/output.mjs";
|
|
34
41
|
import {
|
|
35
42
|
CONTEXT_NAME_RE,
|
|
@@ -71,8 +78,9 @@ import { MANIFEST_PATH, buildManifest, validateSiteStateTree, verifyManifest } f
|
|
|
71
78
|
import { SiteStateFsError, hashBuffer, readSiteStateTree, stagedWriteTree } from "../lib/site-state-fs.mjs";
|
|
72
79
|
import { migrateSiteState } from "../lib/site-migrate.mjs";
|
|
73
80
|
import { applySiteState, downloadAssetBytes, fetchSiteStateExport, planSiteState, publishSiteState, uploadMediaAsset } from "../lib/site-state-client.mjs";
|
|
74
|
-
import { DRAFT_TARGET_AMBIGUOUS, PUBLISH_TARGET_UNCONFIRMED, publishTargetUnconfirmedMessage, diffTheme, draftSyncErrorLine, draftTargetAmbiguousMessage, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
|
|
81
|
+
import { DRAFT_TARGET_AMBIGUOUS, PUBLISH_TARGET_UNCONFIRMED, publishTargetUnconfirmedMessage, diffTheme, draftSyncErrorLine, draftTargetAmbiguousMessage, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance, themeCapacityRefusal } from "../lib/theme-sync.mjs";
|
|
75
82
|
import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
|
|
83
|
+
import { DEFAULT_WORK_INTENT, WORK_INTENT_MAX, WORK_KEY_RE, approvalOutcome, approvalWaitState, cancelWork, getPublishStatus, getWork, isWorkHandle, newWorkKey, publishStatusLines, readSavedWork, requestApproval, resumeWork, safeApprovalUrl, saveWork, sealWork, staleLines, startWork, stateLabel, themeWorkRefusal, workLines } from "../lib/theme-work.mjs";
|
|
76
84
|
import { hyperlink, openUrl } from "../lib/term.mjs";
|
|
77
85
|
import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
|
|
78
86
|
|
|
@@ -86,13 +94,20 @@ const args = process.argv.slice(2);
|
|
|
86
94
|
// 0.8.0: `login --api-key` DEĞERSİZ bayraktır (parser'da boolean) — sır gizli prompt'tan ya da
|
|
87
95
|
// BLOCOFY_API_KEY'den gelir, argv'ye asla girmez. `pages media-uses` / `media-decide` v1 API komutlarıdır.
|
|
88
96
|
const KNOWN = {
|
|
89
|
-
login: ["url", "token", "api-key", "api-url", "keychain"],
|
|
97
|
+
login: ["url", "token", "api-key", "api-url", "keychain", "site", "insecure-storage", "no-browser"],
|
|
98
|
+
init: ["site", "api-url", "insecure-storage", "no-browser"],
|
|
90
99
|
pages: ["decisions", "expected-revision-id", "expected-version", "json", "dir"],
|
|
91
100
|
themePull: ["draft", "instance"],
|
|
92
|
-
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
|
|
101
|
+
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune", "work"],
|
|
93
102
|
themeDev: ["port", "dry", "no-sync", "name", "instance"],
|
|
94
103
|
themePublish: ["instance"],
|
|
95
104
|
themeRename: ["name", "dir"],
|
|
105
|
+
themeWorkStart: ["intent", "idempotency-key"],
|
|
106
|
+
themeWorkStatus: ["dir"],
|
|
107
|
+
themeWorkResume: ["dir", "require-fresh"],
|
|
108
|
+
themeWorkCancel: ["dir"],
|
|
109
|
+
themeWorkSeal: ["dir"],
|
|
110
|
+
themeWorkRequestApproval: ["dir", "open", "wait", "interval"],
|
|
96
111
|
content: [],
|
|
97
112
|
settingsPush: ["instance", "live", "yes", "confirm"],
|
|
98
113
|
pagesPull: ["strict"],
|
|
@@ -152,9 +167,23 @@ function printHelp() {
|
|
|
152
167
|
Develop your theme locally against live data, preview it three ways, and publish.
|
|
153
168
|
|
|
154
169
|
Usage
|
|
155
|
-
blocofy login [--context <name>] [--
|
|
156
|
-
|
|
157
|
-
|
|
170
|
+
blocofy login [--context <name>] [--site <handle|slug>] [--api-url <url>] [--no-browser]
|
|
171
|
+
[--insecure-storage]
|
|
172
|
+
Log in with your browser (PKCE, loopback 127.0.0.1/[::1] callback): pick the site and
|
|
173
|
+
approve. The login is draft-only ("Tema geliştirme"): it can never change the live site or
|
|
174
|
+
publish. "Connected" is printed only after GET /api/v1/ping confirms site + profile. The
|
|
175
|
+
tokens go to the OS secure store (macOS Keychain, Windows DPAPI file, Linux Secret Service);
|
|
176
|
+
no store → refused, never a silent plain file. --insecure-storage (or
|
|
177
|
+
BLOCOFY_SECRET_STORE=file) chooses ~/.blocofy/secrets.json (0600) explicitly.
|
|
178
|
+
Needs a terminal: CI / SSH never opens a browser (use env credentials or the options below).
|
|
179
|
+
--api-url <url> platform (default https://app.blocofy.com)
|
|
180
|
+
--no-browser do not open the browser; open the printed URL yourself
|
|
181
|
+
--site refuse (and revoke) the login unless this site was approved
|
|
182
|
+
|
|
183
|
+
blocofy login --url <url> --token [--context <name>] [--keychain] (advanced)
|
|
184
|
+
Verify a pasted dev token (bcf_… / bcf2_…) against its site (GET /api/dev/whoami) and save
|
|
185
|
+
it as a named context (default name: the site's slug). The token is read from a hidden
|
|
186
|
+
prompt; --token <value> works but warns (argv is visible in ps and shell history).
|
|
158
187
|
Get a token from the admin panel → Settings → Theme CLI tokens.
|
|
159
188
|
--keychain keep the secret in the macOS keychain (or BLOCOFY_SECRET_STORE=keychain);
|
|
160
189
|
default: ~/.blocofy/secrets.json (0600)
|
|
@@ -169,7 +198,18 @@ Usage
|
|
|
169
198
|
blocofy contexts [--json] list saved contexts (never prints secrets)
|
|
170
199
|
blocofy use <name> default context for status / target / pages check outside a
|
|
171
200
|
project (never used by any other command)
|
|
172
|
-
blocofy logout --context <name>
|
|
201
|
+
blocofy logout [--context <name>] remove a context and its secrets; a browser login is also
|
|
202
|
+
revoked on the platform (RFC 7009). Both results are printed;
|
|
203
|
+
exit 4 if the local copy could not be removed or the platform
|
|
204
|
+
login was NOT revoked (then cut it in the panel).
|
|
205
|
+
|
|
206
|
+
blocofy init [dir] [--site <handle|slug>] [--context <name>]
|
|
207
|
+
Set up a new project: (browser login if needed) → a theme work of its own (idempotent:
|
|
208
|
+
.blocofy/init.json keeps its key; re-run continues, never a second work) → the work's theme
|
|
209
|
+
files → .blocofy/project.json → a preview link → the live theme read back unchanged.
|
|
210
|
+
Refuses a non-empty directory that is not a project and one bound to another site
|
|
211
|
+
(exit 3, nothing written). Non-interactive runs need --site and a saved/env credential.
|
|
212
|
+
Exit 5: the live theme changed meanwhile (init never writes it; not reported as success).
|
|
173
213
|
blocofy link [dir] --context <name> [--adopt]
|
|
174
214
|
Bind a project directory to the context's (verified) site: writes .blocofy/project.json
|
|
175
215
|
(commit it), .blocofy/local.json (your context; git-ignored) and .blocofy/.gitignore.
|
|
@@ -193,8 +233,10 @@ Usage
|
|
|
193
233
|
|
|
194
234
|
blocofy theme pull [dir] [--draft] [--instance <handle>]
|
|
195
235
|
Download the live theme to disk. (dir defaults to cwd)
|
|
196
|
-
--draft pull the draft
|
|
197
|
-
|
|
236
|
+
--draft pull the CLI draft (what 'theme dev' / 'theme push --draft' write into)
|
|
237
|
+
instead of live. Read-only: it never creates the draft — with none yet
|
|
238
|
+
it stops (target_missing); run 'blocofy theme push --draft' first.
|
|
239
|
+
Needs a bound project
|
|
198
240
|
--instance <handle> pull a specific theme by its handle (from the admin
|
|
199
241
|
panel theme card, or \`blocofy status\`)
|
|
200
242
|
|
|
@@ -218,6 +260,40 @@ Usage
|
|
|
218
260
|
--prune also REMOVE target files that no longer exist locally, locales/ included
|
|
219
261
|
(lists them first; on the live theme asks to confirm — non-interactive
|
|
220
262
|
shells must add --yes)
|
|
263
|
+
--work <wk_…> write into that WORK's own draft theme (see 'theme work'); never live.
|
|
264
|
+
Needs both the dev token and the v1 API key of the same site. Refused
|
|
265
|
+
unless the work is open; with --live/--instance it is a usage error
|
|
266
|
+
|
|
267
|
+
blocofy theme work start [dir] [--intent "<text>"] [--idempotency-key <k>] (new)
|
|
268
|
+
Start a WORK: a private, safe copy of the site's CURRENT live theme to change and
|
|
269
|
+
later hand over for publication. Visitors never see it. Prints its handle (wk_…) and
|
|
270
|
+
remembers it in .blocofy/local.json (git-ignored). Needs the v1 API key
|
|
271
|
+
(blocofy login --api-key); the work belongs to that key.
|
|
272
|
+
--intent "<text>" a short label of the job (1-120 characters)
|
|
273
|
+
--idempotency-key <k> retry key: the same key returns the SAME work, never a second one.
|
|
274
|
+
Without it a fresh key is generated and printed
|
|
275
|
+
blocofy theme work status <wk_…> [--dir <dir>]
|
|
276
|
+
Show the work: its state, its theme handle, whether the site changed since it started,
|
|
277
|
+
and where its publication stands (review, waiting for approval, published).
|
|
278
|
+
blocofy theme work seal <wk_…> [--dir <dir>]
|
|
279
|
+
Prepare the work for review: its content is frozen and can no longer change. Asks for
|
|
280
|
+
no approval and publishes nothing.
|
|
281
|
+
blocofy theme work request-approval <wk_…> [--dir <dir>] [--open] [--wait [--interval <s>]]
|
|
282
|
+
Ask a person to publish the work (an open work is prepared for review first). Prints the
|
|
283
|
+
approval URL: a signed-in site owner or team member with the theme permission approves it
|
|
284
|
+
in Blocofy. The URL carries no token; having it is not a permission to publish.
|
|
285
|
+
--open also open the approval page in your browser
|
|
286
|
+
--wait wait until it is published, or the request expires, is declined, or the
|
|
287
|
+
live site changes (read-only polling; nothing is published from here)
|
|
288
|
+
--interval <s> seconds between status checks while waiting (1-60, default 3)
|
|
289
|
+
--wait exit codes: 0 published · 2 ended, nothing published (approval_stale, approval_expired,
|
|
290
|
+
approval_declined, approval_superseded, work_cancelled) · 1 wait_timeout or publish_failed
|
|
291
|
+
blocofy theme work resume <wk_…> [--dir <dir>] [--require-fresh]
|
|
292
|
+
Continue that exact work here (it becomes this project's saved work). Writes nothing.
|
|
293
|
+
--require-fresh refuse (work_stale) when the site changed since the work started
|
|
294
|
+
blocofy theme work cancel <wk_…> [--dir <dir>]
|
|
295
|
+
Give the work up. The live site is not touched; its theme stays in the theme library.
|
|
296
|
+
Publication of a work is approved by a person in the admin panel; the CLI never publishes it.
|
|
221
297
|
|
|
222
298
|
blocofy theme rename <handle> <new name> [--dir <dir>]
|
|
223
299
|
Rename a theme (the name is just a label). Works on any of your themes,
|
|
@@ -392,7 +468,8 @@ Usage
|
|
|
392
468
|
blocofy --help
|
|
393
469
|
|
|
394
470
|
Examples
|
|
395
|
-
blocofy login
|
|
471
|
+
blocofy login && blocofy init my-site (browser login, new project)
|
|
472
|
+
blocofy login --url https://store.myblocofy.com --token (pasted dev token, hidden)
|
|
396
473
|
blocofy theme pull store-theme --context store && cd store-theme && blocofy theme dev
|
|
397
474
|
blocofy link ~/code/store-theme --context store (an existing checkout)
|
|
398
475
|
blocofy theme push && blocofy theme publish (inside the bound project)
|
|
@@ -425,16 +502,19 @@ Targets (which site a command talks to)
|
|
|
425
502
|
(one warning; run \`blocofy link --adopt\` to record it). A server that reports no origin cannot
|
|
426
503
|
serve a binding that records one (TARGET_UNVERIFIED).
|
|
427
504
|
Exit codes (every command): 0 ok · 1 usage/network/5xx/local check · 2 server refusal (HTTP 4xx)
|
|
428
|
-
· 3 target/binding refusal · 4 'site apply'
|
|
429
|
-
every step already applied is safe, re-run the same command to resume
|
|
505
|
+
· 3 target/binding refusal · 4 'site apply': not finished within its bounded pass count —
|
|
506
|
+
every step already applied is safe, re-run the same command to resume; 'logout': the local
|
|
507
|
+
login was not removed or the platform login was NOT revoked · 5 'init': the live theme changed
|
|
508
|
+
meanwhile (init never writes it; not reported as a success).
|
|
430
509
|
--json: every failure prints {"error":{"code","message","details"}} as the LAST stderr line; the
|
|
431
510
|
target block ({"target":…}) and any warning lines are printed on stderr before it.
|
|
432
511
|
Retries: network errors and HTTP 429/502/503/504 are retried up to 3 times (Retry-After honoured,
|
|
433
512
|
max 30s per wait; else 0.3s/0.9s/2s), resending the identical request (pages push carries one
|
|
434
513
|
x-idempotency-key per push). HTTP 500 is never retried. Each retry prints a notice on stderr.
|
|
435
514
|
|
|
436
|
-
Auth: ~/.blocofy/credentials.json (contexts, no secrets)
|
|
437
|
-
macOS
|
|
515
|
+
Auth: ~/.blocofy/credentials.json (contexts, no secrets). A browser login's tokens live in the OS
|
|
516
|
+
secure store (macOS Keychain, Windows DPAPI file, Linux Secret Service; the 0600 file only when
|
|
517
|
+
chosen); a pasted token/key in ~/.blocofy/secrets.json (0600) or the macOS keychain. A pre-0.10 credentials file is migrated on first use (backup:
|
|
438
518
|
~/.blocofy/credentials.v1.bak.json — copy it back to roll back).
|
|
439
519
|
The CLI does not build assets — bring your own (npm/Vite/Tailwind); the platform serves
|
|
440
520
|
plain Liquid + static assets.`);
|
|
@@ -466,6 +546,7 @@ const retry = { onRetry };
|
|
|
466
546
|
*/
|
|
467
547
|
function exitCodeFor(error) {
|
|
468
548
|
if (error instanceof TargetError || error instanceof CredentialsError) return error.exitCode ?? 1;
|
|
549
|
+
if (error instanceof LoginError || error instanceof InitError || error?.name === "LoopbackError") return error.exitCode ?? 1;
|
|
469
550
|
if (error instanceof CliRefusal) return 2;
|
|
470
551
|
const status = Number(error?.status);
|
|
471
552
|
return status >= 400 && status < 500 ? 2 : 1;
|
|
@@ -487,8 +568,25 @@ function envelopeFor(error) {
|
|
|
487
568
|
return { code, message: error?.message || String(error), details };
|
|
488
569
|
}
|
|
489
570
|
|
|
571
|
+
/** The context the current command resolved (for the re-login hint of a credential refusal). */
|
|
572
|
+
let activeContext = null;
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* ADR-0014 (wave P4) — the plain Turkish lines of a credential refusal (the profile gate's codes and the CLI's own
|
|
576
|
+
* REAUTH_REQUIRED), printed before the usual `error [code]` line. Never under --json (the envelope carries the code).
|
|
577
|
+
*/
|
|
578
|
+
function printRefusalLines(error) {
|
|
579
|
+
if (JSON_MODE) return;
|
|
580
|
+
if (Array.isArray(error?.lines)) for (const line of error.lines) console.error(redact(line));
|
|
581
|
+
const code = error instanceof CliRefusal ? error.error?.code : error?.details?.server_code ?? error?.code;
|
|
582
|
+
const details = error instanceof CliRefusal ? error.error?.details : error?.serverDetails ?? error?.body?.details ?? error?.details;
|
|
583
|
+
const refusal = credentialRefusal(code, details ?? {}, { context: error?.details?.context ?? activeContext });
|
|
584
|
+
if (refusal) for (const line of refusal.lines) console.error(redact(line));
|
|
585
|
+
}
|
|
586
|
+
|
|
490
587
|
function failAndExit(error) {
|
|
491
588
|
const code = exitCodeFor(error);
|
|
589
|
+
printRefusalLines(error);
|
|
492
590
|
if ((error instanceof PagesCliError || error instanceof SiteStateFsError) && !JSON_MODE) {
|
|
493
591
|
if (error.diagnostics?.length) reportPageDiagnostics(error.diagnostics);
|
|
494
592
|
}
|
|
@@ -529,6 +627,19 @@ function failDraftTargetAmbiguous(error, command, { action = "write" } = {}) {
|
|
|
529
627
|
});
|
|
530
628
|
}
|
|
531
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Theme-capacity (ADR-0013) refusals of the dev theme endpoint: `target_missing`, `quota_exceeded`,
|
|
632
|
+
* `capacity_unavailable`, `resource_busy`, `source_stale`. The plain Turkish explanation first (human mode), then the
|
|
633
|
+
* shared `error [code]` line / `--json` envelope; exit 2 for the 4xx ones, 1 for the 503 ones. Returns only when
|
|
634
|
+
* `error` is not one of them.
|
|
635
|
+
*/
|
|
636
|
+
function failOnThemeCapacityRefusal(error, pushCommand) {
|
|
637
|
+
const refusal = themeCapacityRefusal(error, { pushCommand });
|
|
638
|
+
if (!refusal) return;
|
|
639
|
+
if (!JSON_MODE) for (const line of refusal.lines) console.error(line);
|
|
640
|
+
failAndExit({ code: error.code, status: error.status, message: refusal.message, details: refusal.details });
|
|
641
|
+
}
|
|
642
|
+
|
|
532
643
|
/**
|
|
533
644
|
* Could `instance` be the live theme? Fail-closed (#989 review): yes when it is the live handle, when the live theme is
|
|
534
645
|
* not known, and when it is a raw numeric id (the server accepts one, the live handle comparison cannot see it).
|
|
@@ -587,6 +698,9 @@ function saveVerifiedPair(name, kind, args) {
|
|
|
587
698
|
function saveVerifiedPairLocked(name, kind, { url, secret, identity, storeName }) {
|
|
588
699
|
const store = loadStore();
|
|
589
700
|
const existing = store.contexts[name];
|
|
701
|
+
if (existing?.oauth) {
|
|
702
|
+
throw new TargetError("TARGET_CONTEXT_OCCUPIED", `Context "${name}" is a browser login (blocofy login); a pasted ${kind === "dev" ? "dev token" : "API key"} gets a context of its own. Nothing was saved. Use --context <another-name>.`, { context: name });
|
|
703
|
+
}
|
|
590
704
|
writeSecret(name, kind, storeName, secret);
|
|
591
705
|
if (existing?.[kind] && existing[kind].secret.store !== storeName) {
|
|
592
706
|
try {
|
|
@@ -628,7 +742,7 @@ async function loginApiKey(flags, positionals) {
|
|
|
628
742
|
|
|
629
743
|
let apiKey = null;
|
|
630
744
|
if (process.stdin.isTTY) {
|
|
631
|
-
apiKey = await promptSecret("API key (blcf_live_…, hidden): ");
|
|
745
|
+
apiKey = await promptSecret("API key (blcf_live_… / blcf_k2_…, hidden): ");
|
|
632
746
|
if (apiKey === null) {
|
|
633
747
|
console.error("Cancelled. Nothing was written.");
|
|
634
748
|
process.exit(1);
|
|
@@ -641,7 +755,7 @@ async function loginApiKey(flags, positionals) {
|
|
|
641
755
|
process.exit(1);
|
|
642
756
|
}
|
|
643
757
|
if (!isValidApiKey(apiKey)) {
|
|
644
|
-
console.error("Invalid API key — a v1 key starts with blcf_live_ (a bcf_ dev token is not accepted for the v1 API). Nothing was written.");
|
|
758
|
+
console.error("Invalid API key — a v1 key starts with blcf_live_ or blcf_k2_ (a bcf_ dev token is not accepted for the v1 API). Nothing was written.");
|
|
645
759
|
process.exit(1);
|
|
646
760
|
}
|
|
647
761
|
registerSecret(apiKey);
|
|
@@ -652,22 +766,198 @@ async function loginApiKey(flags, positionals) {
|
|
|
652
766
|
await assertPairFitsContext(name, loadStore().contexts[name], identity, "dev");
|
|
653
767
|
saveVerifiedPair(name, "api", { url: apiUrl, secret: apiKey, identity, storeName });
|
|
654
768
|
console.log(`✓ API key saved to context "${name}" → ${credentialsPath()} (API: ${apiUrl})`);
|
|
769
|
+
console.log(` Secret store: ${storeLabel(storeName)}`);
|
|
655
770
|
console.log(` Site: ${siteLabel(identity.site) || identity.site.id}`);
|
|
656
771
|
console.log("Next: blocofy pages media-uses <page-handle>");
|
|
657
772
|
}
|
|
658
773
|
|
|
774
|
+
// ── browser login (ADR-0014 §5, wave P4) ──────────────────────────────────────────────────────────────────
|
|
775
|
+
|
|
776
|
+
/** Where the owner manages connections (the "Connections and keys" hub). */
|
|
777
|
+
const CONNECTIONS_PATH = "/settings/connections";
|
|
778
|
+
|
|
779
|
+
/** A person at a terminal: stdin is a TTY and CI is not set. Unattended runs never open a browser. */
|
|
780
|
+
const isUnattended = () => !process.stdin.isTTY || Boolean(process.env.CI);
|
|
781
|
+
const isSshSession = () => Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
|
|
782
|
+
|
|
783
|
+
const withLines = (error, lines) => Object.assign(error, { lines });
|
|
784
|
+
|
|
785
|
+
function assertBrowserLoginPossible() {
|
|
786
|
+
if (isUnattended()) {
|
|
787
|
+
throw withLines(
|
|
788
|
+
new LoginError("LOGIN_UNATTENDED", "Browser login needs an interactive terminal (no TTY, or CI is set): no browser was opened and nothing was sent. In CI use BLOCOFY_API_KEY + BLOCOFY_API_URL (or BLOCOFY_URL + BLOCOFY_TOKEN); advanced: blocofy login --token / blocofy login --api-key."),
|
|
789
|
+
[
|
|
790
|
+
"Tarayıcıyla giriş bir terminal ister; bu oturum etkileşimsiz (CI ya da yönlendirilmiş girdi). Tarayıcı açılmadı, hiçbir şey gönderilmedi.",
|
|
791
|
+
" CI/otomasyon: BLOCOFY_API_KEY + BLOCOFY_API_URL (ya da BLOCOFY_URL + BLOCOFY_TOKEN) ortam değişkenleri.",
|
|
792
|
+
" Gelişmiş: yapıştırılan token ile giriş: blocofy login --token ya da blocofy login --api-key",
|
|
793
|
+
],
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
if (isSshSession()) {
|
|
797
|
+
throw withLines(
|
|
798
|
+
new LoginError("LOGIN_NO_BROWSER", "Browser login is not supported over SSH (the browser cannot reach this machine's 127.0.0.1 callback). Nothing was sent. Advanced: blocofy login --token / blocofy login --api-key."),
|
|
799
|
+
[
|
|
800
|
+
"Uzak (SSH) oturumda tarayıcıyla giriş desteklenmiyor: tarayıcı bu makinenin 127.0.0.1 adresine dönemez. Hiçbir şey gönderilmedi.",
|
|
801
|
+
" Gelişmiş: yapıştırılan token ile giriş: blocofy login --token ya da blocofy login --api-key",
|
|
802
|
+
],
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function loginOrigin(flags) {
|
|
808
|
+
const origin = normalizeUrl(typeof flags["api-url"] === "string" ? flags["api-url"] : DEFAULT_API_URL);
|
|
809
|
+
if (!isValidUrl(origin)) throw new LoginError("USAGE", "Invalid --api-url — must be a valid http(s):// URL. Nothing was written.");
|
|
810
|
+
return origin;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** `--site <handle|slug>` names the approved site. */
|
|
814
|
+
const siteMatches = (site, arg) => String(site.id) === String(arg) || (site.slug != null && site.slug === arg);
|
|
815
|
+
|
|
816
|
+
/** A browser login owns its context: never one that holds pasted secrets, never one recorded for another site. */
|
|
817
|
+
function assertContextFreeForLogin(name, identity) {
|
|
818
|
+
const ctx = loadStore().contexts[name];
|
|
819
|
+
if (!ctx) return;
|
|
820
|
+
if (ctx.dev || ctx.api) {
|
|
821
|
+
throw new TargetError("TARGET_CONTEXT_OCCUPIED", `Context "${name}" holds a pasted dev token / API key; a browser login gets a context of its own. Nothing was saved. Use --context <another-name> (or \`blocofy logout --context ${name}\` first).`, { context: name });
|
|
822
|
+
}
|
|
823
|
+
if (identity && ctx.site) {
|
|
824
|
+
const o = compareOrigin(ctx.platform_origin, identity.platformOrigin);
|
|
825
|
+
if (String(ctx.site.id) !== String(identity.site.id) || o === "mismatch" || o === "unproven") {
|
|
826
|
+
throw new TargetError("TARGET_SITE_MISMATCH", `Context "${name}" is for site ${ctx.site.slug ?? ctx.site.id}, but you approved ${identity.site.slug ?? identity.site.id}. Nothing was saved. Use another --context name.`, { context: name, context_site_id: ctx.site.id, new_site_id: identity.site.id });
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Save a verified browser login (token set first, then the context). Returns the previous token set, if any. */
|
|
832
|
+
function saveOAuthContext(name, storeName, { tokens, identity, metadata }, origin) {
|
|
833
|
+
return withStoreLock(() => {
|
|
834
|
+
const store = loadStore();
|
|
835
|
+
const existing = store.contexts[name];
|
|
836
|
+
let previous = null;
|
|
837
|
+
if (existing?.oauth) {
|
|
838
|
+
try {
|
|
839
|
+
const t = readOAuthTokens(name, existing);
|
|
840
|
+
if (t) previous = { token: t.refresh_token ?? t.access_token, endpoint: existing.oauth.revocation_endpoint ?? null };
|
|
841
|
+
} catch {
|
|
842
|
+
previous = null;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
writeOAuthTokens(name, storeName, tokens);
|
|
846
|
+
if (existing?.oauth && existing.oauth.secret.store !== storeName) {
|
|
847
|
+
try {
|
|
848
|
+
removeSecrets(name, { oauth: existing.oauth });
|
|
849
|
+
} catch {
|
|
850
|
+
/* the old copy stays in the other store; the context now points at the new one */
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
store.contexts[name] = {
|
|
854
|
+
platform_origin: identity.platformOrigin ?? origin,
|
|
855
|
+
site: { id: identity.site.id, slug: identity.site.slug ?? null, name: identity.site.name ?? null, domain: identity.site.domain ?? null },
|
|
856
|
+
oauth: {
|
|
857
|
+
url: origin,
|
|
858
|
+
issuer: metadata.issuer,
|
|
859
|
+
client_id: CLI_CLIENT_ID,
|
|
860
|
+
token_endpoint: metadata.token_endpoint,
|
|
861
|
+
revocation_endpoint: metadata.revocation_endpoint ?? null,
|
|
862
|
+
dev_url: identity.devUrl ?? null,
|
|
863
|
+
profile: identity.profile,
|
|
864
|
+
secret: { store: storeName },
|
|
865
|
+
logged_in_at: new Date().toISOString(),
|
|
866
|
+
},
|
|
867
|
+
verified_at: new Date().toISOString(),
|
|
868
|
+
};
|
|
869
|
+
if (!store.current_context) store.current_context = name;
|
|
870
|
+
saveStore(store);
|
|
871
|
+
return previous;
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* The browser login and its save (ADR §5.2): used by `login` and by `init` when it has no credential. Success is the
|
|
877
|
+
* canonical identity probe, then the save. Returns `{ name, identity, storeName, explicitStore, origin }`.
|
|
878
|
+
*/
|
|
879
|
+
async function browserLoginAndSave({ flags, contextName = null, siteArg = null }) {
|
|
880
|
+
assertBrowserLoginPossible();
|
|
881
|
+
const origin = loginOrigin(flags);
|
|
882
|
+
let store;
|
|
883
|
+
try {
|
|
884
|
+
store = oauthSecretStore({ insecure: Boolean(flags["insecure-storage"]) });
|
|
885
|
+
} catch (error) {
|
|
886
|
+
throw error instanceof SecretStoreError ? new CredentialsError(error.code, error.message) : error;
|
|
887
|
+
}
|
|
888
|
+
if (contextName) {
|
|
889
|
+
contextNameOrExit(contextName);
|
|
890
|
+
assertContextFreeForLogin(contextName, null);
|
|
891
|
+
}
|
|
892
|
+
if (!JSON_MODE) console.error(`Blocofy girişi tarayıcıda açılıyor (${origin})…`);
|
|
893
|
+
const result = await runBrowserLogin({
|
|
894
|
+
origin,
|
|
895
|
+
openBrowser: flags["no-browser"] ? async () => {} : async (url) => openUrl(url),
|
|
896
|
+
print: (url) => {
|
|
897
|
+
if (JSON_MODE) return;
|
|
898
|
+
console.error("Tarayıcı açılmazsa bu adresi aç (adres sır içermez):");
|
|
899
|
+
console.error(` ${url}`);
|
|
900
|
+
console.error("Tarayıcıda siteni seçip onayla; en çok 5 dakika bekliyorum…");
|
|
901
|
+
},
|
|
902
|
+
onRetry,
|
|
903
|
+
});
|
|
904
|
+
registerSecret(result.tokens.access_token);
|
|
905
|
+
registerSecret(result.tokens.refresh_token);
|
|
906
|
+
const { identity } = result;
|
|
907
|
+
if (siteArg && !siteMatches(identity.site, siteArg)) {
|
|
908
|
+
await result.abandon(new LoginError("LOGIN_SITE_MISMATCH", `You approved ${identity.site.slug ?? identity.site.id} in the browser, but --site asks for ${siteArg}. The new login was revoked; nothing was saved.`, { site: identity.site.id, requested: siteArg }, 3));
|
|
909
|
+
}
|
|
910
|
+
const name = contextName ?? identity.site.slug ?? "";
|
|
911
|
+
try {
|
|
912
|
+
contextNameOrExit(name);
|
|
913
|
+
assertContextFreeForLogin(name, identity);
|
|
914
|
+
} catch (error) {
|
|
915
|
+
await result.abandon(error);
|
|
916
|
+
}
|
|
917
|
+
const previous = saveOAuthContext(name, store.name, result, origin);
|
|
918
|
+
// A re-login replaces the context's previous login: close that one too (best effort; the hub lists any survivor).
|
|
919
|
+
if (previous?.token && previous.endpoint) await revokeToken({ endpoint: previous.endpoint, token: previous.token });
|
|
920
|
+
return { name, identity, storeName: store.name, explicitStore: store.explicit, origin };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function browserLogin(flags, positionals) {
|
|
924
|
+
if (positionals.length > 0) throw new TargetError("USAGE", "Usage: blocofy login [--context <name>] [--site <handle|slug>] [--api-url <url>] [--no-browser] [--insecure-storage]", {}, 1);
|
|
925
|
+
const siteArg = typeof flags.site === "string" ? flags.site : null;
|
|
926
|
+
const { name, identity, storeName, explicitStore, origin } = await browserLoginAndSave({ flags, contextName: typeof flags.context === "string" ? flags.context : null, siteArg });
|
|
927
|
+
if (explicitStore) printWarning({ code: "SECRET_STORE_PLAINTEXT", message: `The login is kept in ~/.blocofy/secrets.json (0600) because you chose it (--insecure-storage / BLOCOFY_SECRET_STORE=file), not in the OS secure store.` }, { json: JSON_MODE });
|
|
928
|
+
const p = identity.profile;
|
|
929
|
+
if (JSON_MODE) {
|
|
930
|
+
console.log(JSON.stringify({ login: { context: name, site: identity.site, platform_origin: identity.platformOrigin, profile: p, audience: identity.audience, store: storeName, policy_version: identity.policyVersion } }, null, 2));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
console.log(`✓ Bağlandı: ${siteLabel(identity.site) || identity.site.id} — context "${name}"`);
|
|
934
|
+
console.log(` Profil: ${p.label ?? p.id} (${p.id}@${p.version ?? "?"})${p.id === "theme-dev" ? " — taslak üzerinde çalışır; canlı siteni değiştiremez, yayınlayamaz." : ""}`);
|
|
935
|
+
console.log(` Giriş bilgisi: ${storeLabel(storeName)}`);
|
|
936
|
+
if (!identity.devUrl) console.log(" Not: sitenin henüz bir alan adı yok; tema dosyası komutları (theme dev/pull/push) şimdilik çalışmaz.");
|
|
937
|
+
console.log(`Sonraki adım: yeni proje için blocofy init <dizin> · mevcut dizin için blocofy link <dizin> --context ${name}`);
|
|
938
|
+
console.log(` Bağlantıyı panelden kesmek: ${origin}${CONNECTIONS_PATH} · bu makineden çıkış: blocofy logout --context ${name}`);
|
|
939
|
+
console.log(`technical: platform ${identity.platformOrigin ?? origin}, audience ${identity.audience}, policy_version ${identity.policyVersion ?? "?"}`);
|
|
940
|
+
}
|
|
941
|
+
|
|
659
942
|
async function login(rest) {
|
|
660
943
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.login);
|
|
661
|
-
if (flags["api-key"]
|
|
662
|
-
if (!flags["api-key"]) {
|
|
663
|
-
console.error("--api-url is only used with --api-key. Nothing was written.");
|
|
664
|
-
process.exit(1);
|
|
665
|
-
}
|
|
944
|
+
if (flags["api-key"]) {
|
|
666
945
|
await loginApiKey(flags, positionals);
|
|
667
946
|
return;
|
|
668
947
|
}
|
|
948
|
+
// ADR-0014 O2: the browser is the default; a pasted dev token (`--url` / `--token` / `--keychain`) is the advanced path.
|
|
949
|
+
if (flags.url === undefined && flags.token === undefined && !flags.keychain) {
|
|
950
|
+
await browserLogin(flags, positionals);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (flags["api-url"] !== undefined || flags.site !== undefined || flags["insecure-storage"] || flags["no-browser"]) {
|
|
954
|
+
console.error("--api-url / --site / --insecure-storage / --no-browser belong to the browser login (`blocofy login`) or `--api-key`; a pasted dev token takes --url and --token. Nothing was written.");
|
|
955
|
+
process.exit(1);
|
|
956
|
+
}
|
|
669
957
|
let url = typeof flags.url === "string" ? normalizeUrl(flags.url) : "";
|
|
670
958
|
let token = typeof flags.token === "string" ? flags.token.trim() : "";
|
|
959
|
+
// ADR-0014 B6: a token on the command line is visible to other local users (ps) and kept in shell history.
|
|
960
|
+
if (token) printWarning({ code: "TOKEN_IN_ARGV", message: "The dev token was given on the command line, where `ps` and your shell history can see it. Prefer `blocofy login --url <site> --token` (hidden prompt) or BLOCOFY_URL + BLOCOFY_TOKEN." }, { json: JSON_MODE });
|
|
671
961
|
|
|
672
962
|
// Flag ile verildiyse anında doğrula (prompt'a düşmeden).
|
|
673
963
|
if (url && !isValidUrl(url)) {
|
|
@@ -678,7 +968,7 @@ async function login(rest) {
|
|
|
678
968
|
console.error(
|
|
679
969
|
token.startsWith("blcf_")
|
|
680
970
|
? "Invalid --token — that is a v1 API key (blcf_…), not a theme dev token (bcf_…). The two are separate credentials: add the API key with `blocofy login --api-key`. Nothing was written."
|
|
681
|
-
: "Invalid --token — must start with bcf_.",
|
|
971
|
+
: "Invalid --token — must start with bcf_ or bcf2_.",
|
|
682
972
|
);
|
|
683
973
|
process.exit(1);
|
|
684
974
|
}
|
|
@@ -697,13 +987,30 @@ async function login(rest) {
|
|
|
697
987
|
"Enter a valid URL, e.g. https://store.myblocofy.com",
|
|
698
988
|
);
|
|
699
989
|
}
|
|
990
|
+
if (!token && process.stdin.isTTY) {
|
|
991
|
+
// ADR-0014 B6: the dev token prompt does not echo.
|
|
992
|
+
rl.close();
|
|
993
|
+
for (let i = 0; i < 3 && !token; i += 1) {
|
|
994
|
+
const answer = await promptSecret("Dev token (bcf_… / bcf2_…, hidden): ");
|
|
995
|
+
if (answer === null) {
|
|
996
|
+
console.error("Cancelled. Nothing was written.");
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
if (isValidToken(answer.trim())) token = answer.trim();
|
|
1000
|
+
else console.error(" ✗ Token must start with bcf_ or bcf2_ — get one from the admin panel: Settings → Theme CLI tokens.");
|
|
1001
|
+
}
|
|
1002
|
+
if (!token) {
|
|
1003
|
+
console.error("Too many invalid attempts.");
|
|
1004
|
+
process.exit(1);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
700
1007
|
if (!token) {
|
|
701
1008
|
token = await promptValid(
|
|
702
1009
|
rl,
|
|
703
1010
|
"Dev token (bcf_…): ",
|
|
704
1011
|
(s) => s.trim(),
|
|
705
1012
|
isValidToken,
|
|
706
|
-
"Token must start with bcf_ — get one from the admin panel: Settings → Theme CLI tokens.",
|
|
1013
|
+
"Token must start with bcf_ or bcf2_ — get one from the admin panel: Settings → Theme CLI tokens.",
|
|
707
1014
|
);
|
|
708
1015
|
}
|
|
709
1016
|
} finally {
|
|
@@ -721,6 +1028,7 @@ async function login(rest) {
|
|
|
721
1028
|
saveVerifiedPair(name, "dev", { url, secret: token, identity, storeName });
|
|
722
1029
|
console.log(`✓ Saved context "${name}" → ${credentialsPath()}`);
|
|
723
1030
|
console.log(` Site: ${siteLabel(identity.site)} — commands using context "${name}" target this site.`);
|
|
1031
|
+
console.log(` Secret store: ${storeLabel(storeName)}`);
|
|
724
1032
|
console.log(`Next: bind a project directory: blocofy link <dir> --context ${name} (or pull into an empty one: blocofy theme pull <dir> --context ${name})`);
|
|
725
1033
|
}
|
|
726
1034
|
|
|
@@ -735,6 +1043,7 @@ async function contextsCommand(rest) {
|
|
|
735
1043
|
platform_origin: c.platform_origin ?? null,
|
|
736
1044
|
dev: c.dev ? { url: c.dev.url, store: c.dev.secret.store } : null,
|
|
737
1045
|
api: c.api ? { url: c.api.url, store: c.api.secret.store } : null,
|
|
1046
|
+
login: c.oauth ? { url: c.oauth.url, store: c.oauth.secret.store, profile: c.oauth.profile ?? null, state: c.oauth.state ?? "active" } : null,
|
|
738
1047
|
verified_at: c.verified_at ?? null,
|
|
739
1048
|
}));
|
|
740
1049
|
if (JSON_MODE) {
|
|
@@ -747,7 +1056,8 @@ async function contextsCommand(rest) {
|
|
|
747
1056
|
}
|
|
748
1057
|
for (const r of rows) {
|
|
749
1058
|
const site = r.site ? `${siteLabel(r.site)} · ${r.site.id}` : "(not verified yet)";
|
|
750
|
-
const
|
|
1059
|
+
const login = r.login ? `cli-login ${r.login.url} [${r.login.store}]${r.login.profile ? ` ${r.login.profile.id}@${r.login.profile.version}` : ""}${r.login.state === "reauth_required" ? " (needs a new login)" : ""}` : null;
|
|
1060
|
+
const pairs = [r.dev ? `dev ${r.dev.url} [${r.dev.store}]` : null, r.api ? `api ${r.api.url} [${r.api.store}]` : null, login].filter(Boolean).join(", ");
|
|
751
1061
|
console.log(`${r.current ? "*" : " "} ${r.name} ${site} ${pairs}`);
|
|
752
1062
|
}
|
|
753
1063
|
}
|
|
@@ -766,20 +1076,82 @@ async function useCommand(rest) {
|
|
|
766
1076
|
console.log(" (Inside a bound project the project's site decides; `use` never retargets it.)");
|
|
767
1077
|
}
|
|
768
1078
|
|
|
1079
|
+
/**
|
|
1080
|
+
* ADR-0014 §5.5 — `blocofy logout [--context <name>]`. Two independent results, both printed:
|
|
1081
|
+
* server revoked (RFC 7009 /revoke answered 200) | unreachable (network / 5xx: NOT revoked) | refused (another
|
|
1082
|
+
* answer: NOT revoked) | unavailable (the saved login could not be read: NOT revoked) | not_applicable
|
|
1083
|
+
* (a pasted token or key: revoke it in the panel)
|
|
1084
|
+
* local cleared | failed — done whatever the server said.
|
|
1085
|
+
* Exit 0 only when the local login is cleared and nothing is left open on the server; otherwise 4.
|
|
1086
|
+
*/
|
|
769
1087
|
async function logoutCommand(rest) {
|
|
770
1088
|
const { flags, positionals } = parseArgsOrExit(rest, []);
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1089
|
+
if (positionals.length) throw new TargetError("USAGE", "Usage: blocofy logout [--context <name>]", {}, 1);
|
|
1090
|
+
const store0 = loadStore();
|
|
1091
|
+
const name = typeof flags.context === "string" ? flags.context : store0.current_context;
|
|
1092
|
+
if (!name) throw new TargetError("USAGE", "Usage: blocofy logout --context <name> (no default context is set; see `blocofy contexts`).", {}, 1);
|
|
1093
|
+
const ctx = store0.contexts[name];
|
|
1094
|
+
if (!ctx) throw new TargetError("TARGET_CONTEXT_UNKNOWN", `No context named "${name}".`, { context: name });
|
|
1095
|
+
|
|
1096
|
+
let server = { outcome: "not_applicable", status: null };
|
|
1097
|
+
if (ctx.oauth) {
|
|
1098
|
+
let tokens = null;
|
|
1099
|
+
try {
|
|
1100
|
+
tokens = readOAuthTokens(name, ctx);
|
|
1101
|
+
} catch {
|
|
1102
|
+
tokens = null;
|
|
1103
|
+
}
|
|
1104
|
+
const token = tokens?.refresh_token ?? tokens?.access_token ?? null;
|
|
1105
|
+
if (token) registerSecret(token);
|
|
1106
|
+
server = token && ctx.oauth.revocation_endpoint
|
|
1107
|
+
? await revokeToken({ endpoint: ctx.oauth.revocation_endpoint, token, clientId: ctx.oauth.client_id ?? CLI_CLIENT_ID })
|
|
1108
|
+
: { outcome: "unavailable", status: null };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
let local = "cleared";
|
|
1112
|
+
let localError = null;
|
|
1113
|
+
try {
|
|
1114
|
+
withStoreLock(() => {
|
|
1115
|
+
const store = loadStore();
|
|
1116
|
+
const current = store.contexts[name];
|
|
1117
|
+
if (!current) return;
|
|
1118
|
+
removeSecrets(name, current);
|
|
1119
|
+
delete store.contexts[name];
|
|
1120
|
+
if (store.current_context === name) store.current_context = null;
|
|
1121
|
+
saveStore(store);
|
|
1122
|
+
});
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
local = "failed";
|
|
1125
|
+
localError = error;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const hub = ctx.oauth ? `${String(ctx.oauth.url).replace(/\/+$/, "")}${CONNECTIONS_PATH}` : null;
|
|
1129
|
+
const ok = local === "cleared" && (server.outcome === "revoked" || server.outcome === "not_applicable");
|
|
1130
|
+
if (JSON_MODE) {
|
|
1131
|
+
console.log(JSON.stringify({ logout: { context: name, local, server: server.outcome, ...(server.status ? { server_status: server.status } : {}), ...(hub && server.outcome !== "revoked" ? { revoke_in_panel: hub } : {}) } }, null, 2));
|
|
1132
|
+
} else {
|
|
1133
|
+
console.log(local === "cleared" ? `✓ Yerel giriş silindi (context "${name}").` : `✗ Yerel giriş SİLİNEMEDİ (context "${name}"): ${redact(localError?.message ?? String(localError))}`);
|
|
1134
|
+
switch (server.outcome) {
|
|
1135
|
+
case "revoked":
|
|
1136
|
+
console.log("✓ Sunucudaki CLI bağlantısı iptal edildi.");
|
|
1137
|
+
break;
|
|
1138
|
+
case "not_applicable":
|
|
1139
|
+
console.log(" Sunucuda iptal edilecek bir CLI girişi yok: bu context yapıştırılmış bir token/anahtar taşıyordu; onu panelden iptal edebilirsin.");
|
|
1140
|
+
break;
|
|
1141
|
+
case "unreachable":
|
|
1142
|
+
console.log(`✗ Sunucudaki bağlantı İPTAL EDİLMEDİ (platforma ulaşılamadı${server.status ? `, HTTP ${server.status}` : ""}) — panelden kes: ${hub}`);
|
|
1143
|
+
break;
|
|
1144
|
+
case "refused":
|
|
1145
|
+
console.log(`✗ Sunucudaki bağlantı İPTAL EDİLMEDİ (platform isteği reddetti, HTTP ${server.status}) — panelden kes: ${hub}`);
|
|
1146
|
+
break;
|
|
1147
|
+
default:
|
|
1148
|
+
console.log(`✗ Sunucudaki bağlantı İPTAL EDİLMEDİ (kayıtlı giriş okunamadı) — panelden kes: ${hub}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (!ok) {
|
|
1152
|
+
if (!JSON_MODE) console.error(`logout: local ${local}, server ${server.outcome} — not complete (exit 4).`);
|
|
1153
|
+
process.exit(4);
|
|
1154
|
+
}
|
|
783
1155
|
}
|
|
784
1156
|
|
|
785
1157
|
async function linkCommand(rest) {
|
|
@@ -791,8 +1163,7 @@ async function linkCommand(rest) {
|
|
|
791
1163
|
if (!flagContext && !process.env.BLOCOFY_CONTEXT && !envCtx) {
|
|
792
1164
|
throw new TargetError("TARGET_CONTEXT_REQUIRED", "`blocofy link` needs the context to bind: pass --context <name> (see `blocofy contexts`).", {});
|
|
793
1165
|
}
|
|
794
|
-
const resolved = await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null });
|
|
795
|
-
const secrets = resolved.env ? resolved.env.secrets : readSecrets(resolved.name, resolved.context);
|
|
1166
|
+
const { resolved, secrets } = await credentialsFor(await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null }));
|
|
796
1167
|
registerSecret(secrets.devToken);
|
|
797
1168
|
registerSecret(secrets.apiKey);
|
|
798
1169
|
assertCredentialTypes({ resolved, secrets });
|
|
@@ -832,6 +1203,31 @@ async function targetCommand(rest) {
|
|
|
832
1203
|
else printTarget(t.display, { stream: process.stdout });
|
|
833
1204
|
}
|
|
834
1205
|
|
|
1206
|
+
/**
|
|
1207
|
+
* ADR-0014 §5 — the secrets of a resolved context. A browser-login context answers with its (renewed) CLI token in
|
|
1208
|
+
* both places: the v1 API at its platform origin and, when the site has a domain, the renderer's `/api/dev`.
|
|
1209
|
+
* Returns `{ resolved, secrets, renewToken }` (`renewToken`: a getter for long-running commands, else null).
|
|
1210
|
+
*/
|
|
1211
|
+
async function credentialsFor(resolved) {
|
|
1212
|
+
activeContext = resolved.name;
|
|
1213
|
+
if (resolved.env) return { resolved, secrets: resolved.env.secrets, renewToken: null };
|
|
1214
|
+
const ctx = resolved.context;
|
|
1215
|
+
if (!ctx.oauth) return { resolved, secrets: readSecrets(resolved.name, ctx), renewToken: null };
|
|
1216
|
+
const access = await accessTokenFor(resolved.name);
|
|
1217
|
+
registerSecret(access);
|
|
1218
|
+
const view = { ...ctx, api: { url: ctx.oauth.url, secret: ctx.oauth.secret } };
|
|
1219
|
+
if (ctx.oauth.dev_url) view.dev = { url: ctx.oauth.dev_url, secret: ctx.oauth.secret };
|
|
1220
|
+
return {
|
|
1221
|
+
resolved: { ...resolved, context: view, oauth: true },
|
|
1222
|
+
secrets: { devToken: ctx.oauth.dev_url ? access : null, apiKey: access },
|
|
1223
|
+
renewToken: async () => {
|
|
1224
|
+
const t = await accessTokenFor(resolved.name);
|
|
1225
|
+
registerSecret(t);
|
|
1226
|
+
return t;
|
|
1227
|
+
},
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
835
1231
|
/** "BLOCOFY_CONTEXT=beta, env credentials" — the stated context choices an explicit --context overrode. */
|
|
836
1232
|
function overridesLabel(ignored) {
|
|
837
1233
|
return ignored.map((a) => (a.source === "env" ? "env credentials" : `${a.source}=${a.name}`)).join(", ");
|
|
@@ -875,7 +1271,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
875
1271
|
const { newBinding } = enforceBindingPolicy({ commandClass, binding, dir, command });
|
|
876
1272
|
const envCtx = envContext();
|
|
877
1273
|
let cachedStore = null;
|
|
878
|
-
const
|
|
1274
|
+
const chosen = await resolveContext({
|
|
879
1275
|
flagContext: typeof flags.context === "string" ? flags.context : null,
|
|
880
1276
|
envContextName: process.env.BLOCOFY_CONTEXT || null,
|
|
881
1277
|
envCtx,
|
|
@@ -885,14 +1281,17 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
885
1281
|
isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
886
1282
|
prompt: promptContext,
|
|
887
1283
|
});
|
|
888
|
-
precheckContext({ binding, resolved });
|
|
889
|
-
const secrets
|
|
1284
|
+
precheckContext({ binding, resolved: chosen });
|
|
1285
|
+
const { resolved, secrets, renewToken } = await credentialsFor(chosen);
|
|
890
1286
|
registerSecret(secrets.devToken);
|
|
891
1287
|
registerSecret(secrets.apiKey);
|
|
892
1288
|
assertCredentialTypes({ resolved, secrets });
|
|
893
1289
|
|
|
894
1290
|
const hasDev = Boolean(resolved.context.dev && secrets.devToken);
|
|
895
1291
|
const hasApi = Boolean(resolved.context.api && secrets.apiKey);
|
|
1292
|
+
if (needs === "dev" && !hasDev && resolved.oauth) {
|
|
1293
|
+
throw new TargetError("LOGIN_REQUIRED", `Context "${resolved.name}" is a CLI login, but the site has no domain yet, so the theme-file endpoints (/api/dev) cannot be reached. Nothing was sent. Give the site a domain in the panel, then log in again: blocofy login --context ${resolved.name}`, { context: resolved.name }, 1);
|
|
1294
|
+
}
|
|
896
1295
|
if (needs === "dev" && !hasDev) {
|
|
897
1296
|
throw new TargetError("LOGIN_REQUIRED", `Login required: context "${resolved.name}" has no dev token. Run \`blocofy login${resolved.env ? "" : ` --context ${resolved.name}`}\` (or set BLOCOFY_URL + BLOCOFY_TOKEN).`, { context: resolved.name }, 1);
|
|
898
1297
|
}
|
|
@@ -907,7 +1306,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
907
1306
|
if (needs === "both" && !hasDev) {
|
|
908
1307
|
throw new TargetError(
|
|
909
1308
|
"LOGIN_REQUIRED",
|
|
910
|
-
`Login required: context "${resolved.name}" has no dev token. Run \`blocofy login${resolved.env ? "" : ` --context ${resolved.name}`}\` (or set BLOCOFY_URL + BLOCOFY_TOKEN).
|
|
1309
|
+
`Login required: context "${resolved.name}" has no dev token. Run \`blocofy login${resolved.env ? "" : ` --context ${resolved.name}`}\` (or set BLOCOFY_URL + BLOCOFY_TOKEN). This command needs both a dev token and a v1 API key (site plan/apply, theme push --work).`,
|
|
911
1310
|
{ context: resolved.name },
|
|
912
1311
|
1,
|
|
913
1312
|
);
|
|
@@ -929,6 +1328,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
929
1328
|
binding,
|
|
930
1329
|
newBinding,
|
|
931
1330
|
display,
|
|
1331
|
+
renewToken,
|
|
932
1332
|
};
|
|
933
1333
|
}
|
|
934
1334
|
|
|
@@ -961,7 +1361,9 @@ async function themePull(rest) {
|
|
|
961
1361
|
const draft = Boolean(flags.draft);
|
|
962
1362
|
const instance = typeof flags.instance === "string" ? flags.instance : null;
|
|
963
1363
|
const what = instance ? `instance ${instance}` : draft ? "draft" : "live";
|
|
964
|
-
// Review M1: a draft pull
|
|
1364
|
+
// Review M1: a draft pull was classed a remote mutation (binding required) because `?draft=1` used to provision the
|
|
1365
|
+
// draft server-side. A current platform never creates a theme on a read (ADR-0013 D6: 404 `target_missing`); the
|
|
1366
|
+
// class is kept fail-closed for platforms from before that change.
|
|
965
1367
|
const target = await prepareTarget({ command: draft ? "theme pull --draft" : "theme pull", commandClass: draft ? "remote-mutation" : "local-write", dir, flags, needs: "dev", mode: what });
|
|
966
1368
|
let count;
|
|
967
1369
|
try {
|
|
@@ -971,12 +1373,71 @@ async function themePull(rest) {
|
|
|
971
1373
|
if (error?.code === "draft_target_unverifiable") {
|
|
972
1374
|
failAndExit({ code: "draft_target_unverifiable", status: 503, message: "The platform could not verify which draft to pull (a read failed on its side). Nothing was written. Try again in a moment.", details: {} });
|
|
973
1375
|
}
|
|
1376
|
+
failOnThemeCapacityRefusal(error, commandLine("blocofy theme push", positionals));
|
|
974
1377
|
throw error;
|
|
975
1378
|
}
|
|
976
1379
|
console.log(`Downloaded ${count} ${what} theme files → ${dir}`);
|
|
977
1380
|
bindAfterPull(target, dir);
|
|
978
1381
|
}
|
|
979
1382
|
|
|
1383
|
+
// ── draft-only logins write into their own theme work (ADR-0014 §3.5, P4 follow-up) ───────────────────────
|
|
1384
|
+
|
|
1385
|
+
/** Profiles that never write a live or implicit target: their writes go to their own theme work. */
|
|
1386
|
+
const DRAFT_ONLY_PROFILES = new Set(["theme-dev", "review", "service-ci"]);
|
|
1387
|
+
|
|
1388
|
+
/** The project's saved work: `.blocofy/local.json` (theme work start / init), else `.blocofy/init.json`. */
|
|
1389
|
+
function savedWorkOf(binding) {
|
|
1390
|
+
const local = readSavedWork(binding);
|
|
1391
|
+
if (local?.handle && local.theme) return local;
|
|
1392
|
+
let init = null;
|
|
1393
|
+
try {
|
|
1394
|
+
init = readInitState(binding.root);
|
|
1395
|
+
} catch {
|
|
1396
|
+
init = null;
|
|
1397
|
+
}
|
|
1398
|
+
if (init && isWorkHandle(init.work_handle) && String(init.site_id) === String(binding.project.site_id)) {
|
|
1399
|
+
return { handle: init.work_handle, theme: typeof init.work_theme === "string" ? init.work_theme : null };
|
|
1400
|
+
}
|
|
1401
|
+
return local?.handle ? local : null;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Offline (no request): when the context this command will use carries a draft-only profile (a browser login, or a
|
|
1406
|
+
* recorded profile), `{ profile, work }` with the project's saved work (or null); otherwise null — then the command
|
|
1407
|
+
* behaves as before. Any resolution problem returns null and is reported by `prepareTarget` as usual.
|
|
1408
|
+
*/
|
|
1409
|
+
async function draftOnlyDefaults(dir, flags) {
|
|
1410
|
+
let binding;
|
|
1411
|
+
let resolved;
|
|
1412
|
+
try {
|
|
1413
|
+
binding = findBinding(dir);
|
|
1414
|
+
resolved = await resolveContext({ flagContext: typeof flags.context === "string" ? flags.context : null, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx: envContext(), getStore: () => loadStore(), binding, isTTY: false, prompt: null });
|
|
1415
|
+
} catch {
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
const profile = resolved.context?.oauth?.profile ?? resolved.context?.profile ?? null;
|
|
1419
|
+
if (!profile || !DRAFT_ONLY_PROFILES.has(profile.id)) return null;
|
|
1420
|
+
return { profile, work: binding ? savedWorkOf(binding) : null };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function themeWorkRequired(profile, command) {
|
|
1424
|
+
return withLines(
|
|
1425
|
+
new TargetError(
|
|
1426
|
+
"THEME_WORK_REQUIRED",
|
|
1427
|
+
`This login is draft-only (profile ${profile.id}): \`${command}\` writes only into its own theme work, and this project has no saved work. Nothing was sent. Set one up: blocofy init <dir> (a new project) or blocofy theme work start (this project); or name the target: --work <wk_…> / --instance <theme>.`,
|
|
1428
|
+
{ profile: profile.id },
|
|
1429
|
+
3,
|
|
1430
|
+
),
|
|
1431
|
+
[
|
|
1432
|
+
`Bu giriş yalnız taslak üzerinde çalışır ("${profile.label ?? profile.id}"): \`${command}\` yalnız kendi tema çalışmasına yazar ve bu projede kayıtlı bir tema çalışması yok. Hiçbir şey gönderilmedi.`,
|
|
1433
|
+
" Yeni proje: blocofy init <dizin> · bu projede yeni çalışma: blocofy theme work start",
|
|
1434
|
+
" Ya da hedefi açıkça ver: --work <wk_…> / --instance <tema>",
|
|
1435
|
+
],
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const savedWorkNote = (work) => `Kayıtlı tema çalışması kullanılıyor: ${work.handle}${work.theme ? ` (tema ${work.theme})` : ""} — başka bir hedef için --work / --instance ver.`;
|
|
1440
|
+
|
|
980
1441
|
async function themePush(rest) {
|
|
981
1442
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePush);
|
|
982
1443
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
@@ -984,22 +1445,44 @@ async function themePush(rest) {
|
|
|
984
1445
|
console.error(`Theme directory not found: ${dir}`);
|
|
985
1446
|
process.exit(1);
|
|
986
1447
|
}
|
|
987
|
-
|
|
1448
|
+
let instanceFlag = typeof flags.instance === "string" ? flags.instance : null;
|
|
988
1449
|
const name = typeof flags.name === "string" ? flags.name : null;
|
|
989
1450
|
const dryRun = Boolean(flags["dry-run"] || flags.validate);
|
|
1451
|
+
// CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
|
|
1452
|
+
// target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
|
|
1453
|
+
const readOnly = Boolean(flags.diff) || dryRun;
|
|
1454
|
+
// Theme work: `--work <wk_…>` names the target itself — that work's own draft theme, read from the v1 API (the
|
|
1455
|
+
// work belongs to the API key) and written through the dev token as `--draft --instance <work theme>`. Both pairs
|
|
1456
|
+
// must resolve to the same site (prepareTarget "both"); the work must be open. Never live.
|
|
1457
|
+
// A draft-only login with no explicit target writes into the project's saved work (explicit flags win).
|
|
1458
|
+
let savedWork = null;
|
|
1459
|
+
if (flags.work === undefined && !instanceFlag && !flags.live) {
|
|
1460
|
+
const d = await draftOnlyDefaults(dir, flags);
|
|
1461
|
+
if (d) {
|
|
1462
|
+
if (!d.work) throw themeWorkRequired(d.profile, "theme push");
|
|
1463
|
+
savedWork = d.work.handle;
|
|
1464
|
+
if (!JSON_MODE) console.error(savedWorkNote(d.work));
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
const workHandle = flags.work === undefined ? savedWork : flags.work;
|
|
1468
|
+
let workTarget = null;
|
|
1469
|
+
if (workHandle !== null) {
|
|
1470
|
+
if (!isWorkHandle(workHandle)) throw new TargetError("USAGE", "--work needs a work handle (wk_…, from `blocofy theme work start`). Nothing was sent.", {}, 1);
|
|
1471
|
+
if (flags.live || instanceFlag) throw new TargetError("USAGE", "--work names the target itself; it cannot be combined with --live or --instance. Nothing was sent.", {}, 1);
|
|
1472
|
+
workTarget = await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "both", mode: `${readOnly ? (flags.diff ? "read · diff vs " : "read · dry run · ") : "draft · "}work ${workHandle}` });
|
|
1473
|
+
instanceFlag = (await openWorkTheme(workTarget.api, workHandle)).theme;
|
|
1474
|
+
}
|
|
1475
|
+
const draftFlag = Boolean(flags.draft) || workHandle !== null;
|
|
990
1476
|
// Yeni varsayılan hedef: DRAFT (güvenli). `--live` eski anında-canlı davranışını
|
|
991
1477
|
// açıkça geri getirir; `--instance` belirli bir temayı adresler. Sadece "live"
|
|
992
1478
|
// modu canlıya yazar ve onay gerektirir.
|
|
993
1479
|
const { mode, instance } = resolvePushMode({
|
|
994
1480
|
live: Boolean(flags.live),
|
|
995
|
-
draft:
|
|
1481
|
+
draft: draftFlag,
|
|
996
1482
|
instance: instanceFlag,
|
|
997
1483
|
});
|
|
998
|
-
// CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
|
|
999
|
-
// target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
|
|
1000
|
-
const readOnly = Boolean(flags.diff) || dryRun;
|
|
1001
1484
|
const opMode = flags.diff ? `read · diff vs ${instanceFlag ? `instance ${instanceFlag}` : mode === "draft" ? "CLI draft" : "live"}` : dryRun ? `read · dry run (${mode === "instance" ? `instance ${instance}` : mode})` : mode === "instance" ? `instance ${instance}` : mode;
|
|
1002
|
-
const target = await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: `${opMode}${flags.prune && !readOnly ? " · prune" : ""}` });
|
|
1485
|
+
const target = workTarget ?? (await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: `${opMode}${flags.prune && !readOnly ? " · prune" : ""}` }));
|
|
1003
1486
|
const creds = target.dev;
|
|
1004
1487
|
// 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
|
|
1005
1488
|
// OLMADAN header'lar tek başına legacy writer'a düşer ve --live push pinned render'a YANSIMAZ (M4
|
|
@@ -1044,7 +1527,7 @@ async function themePush(rest) {
|
|
|
1044
1527
|
const whoami = target.identity;
|
|
1045
1528
|
const siteName = siteLabel(whoami.site) || String(whoami.site.id);
|
|
1046
1529
|
if (mode === "instance") {
|
|
1047
|
-
console.log(`→ Pushing to theme ${instance} of ${siteName}${whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? " (the LIVE theme)" : ""}`);
|
|
1530
|
+
console.log(`→ Pushing to theme ${instance} of ${siteName}${workHandle ? ` (work ${workHandle})` : whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? " (the LIVE theme)" : ""}`);
|
|
1048
1531
|
} else if (mode === "live") {
|
|
1049
1532
|
console.log(dryRun ? `→ Validating against the LIVE theme of ${siteName} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${siteName}`);
|
|
1050
1533
|
} else {
|
|
@@ -1061,7 +1544,7 @@ async function themePush(rest) {
|
|
|
1061
1544
|
const instanceIsLive = mode === "instance" && instanceMaybeLive(instance, whoami.liveThemeId);
|
|
1062
1545
|
// `--draft --instance <h>`: a draft write to a chosen draft. It never reaches the live theme — refused here when the
|
|
1063
1546
|
// handle may be live (fail-closed), and by the server (422 draft_target_is_live) in any case.
|
|
1064
|
-
const draftInstance = mode === "instance" &&
|
|
1547
|
+
const draftInstance = mode === "instance" && draftFlag;
|
|
1065
1548
|
if (draftInstance && instanceIsLive && !dryRun) {
|
|
1066
1549
|
console.error(`✗ --draft --instance ${instance}: ${whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? "that is the LIVE theme" : "this may be the LIVE theme (it could not be told apart from it)"}; a draft push never writes live. Nothing was written.`);
|
|
1067
1550
|
console.error(" Pass a draft's handle (`blocofy status`), or drop --draft and confirm a live push.");
|
|
@@ -1182,6 +1665,8 @@ async function themePush(rest) {
|
|
|
1182
1665
|
}
|
|
1183
1666
|
failAndExit({ code: "idempotency_conflict", status: error.status, message: "The idempotency key was already used with different content. Retry with a new key (or omit --idempotency-key).", details: {} });
|
|
1184
1667
|
}
|
|
1668
|
+
// ADR-0013: the new draft this push needs was refused (definite answer, before the unknown-outcome handling below).
|
|
1669
|
+
failOnThemeCapacityRefusal(error, commandLine("blocofy theme push", positionals));
|
|
1185
1670
|
const refusal = themePushRefusal(error, { draft: mode === "draft", idempotencyKey });
|
|
1186
1671
|
if (refusal) failAndExit({ code: error.code, status: error.status, ...refusal });
|
|
1187
1672
|
throw error;
|
|
@@ -1223,6 +1708,10 @@ async function themePush(rest) {
|
|
|
1223
1708
|
console.log(`✓ Deployed: ${ids}. The written files were not verified (this server does not read them back).`);
|
|
1224
1709
|
}
|
|
1225
1710
|
if (result.convergence === "converged") console.log(" (already applied by an earlier push with the same idempotency key)");
|
|
1711
|
+
if (workHandle) {
|
|
1712
|
+
console.log(`Çalışma ${workHandle} güncellendi (tema ${instance}). Canlı site değişmedi.`);
|
|
1713
|
+
console.log(`Durumu: blocofy theme work status ${workHandle}`);
|
|
1714
|
+
}
|
|
1226
1715
|
if (mode === "draft") {
|
|
1227
1716
|
const h = typeof result.targetInstance === "string" ? result.targetInstance : null;
|
|
1228
1717
|
const label = result.targetName ? ` "${result.targetName}"` : "";
|
|
@@ -2070,12 +2559,22 @@ async function themeDev(rest) {
|
|
|
2070
2559
|
|
|
2071
2560
|
// CF-T2: draft sync mutates the site (remote-mutation, binding required); `--no-sync` only renders (read).
|
|
2072
2561
|
const noSync = Boolean(flags["no-sync"]);
|
|
2562
|
+
// A draft-only login syncs into the project's saved work's theme unless --instance names another target.
|
|
2563
|
+
let savedInstance = null;
|
|
2564
|
+
if (!noSync && typeof flags.instance !== "string") {
|
|
2565
|
+
const d = await draftOnlyDefaults(themeDir, flags);
|
|
2566
|
+
if (d) {
|
|
2567
|
+
if (!d.work?.theme) throw themeWorkRequired(d.profile, "theme dev");
|
|
2568
|
+
savedInstance = d.work.theme;
|
|
2569
|
+
if (!JSON_MODE) console.error(savedWorkNote(d.work));
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2073
2572
|
const target = await prepareTarget({ command: "theme dev", commandClass: noSync ? "read" : "remote-mutation", dir: themeDir, flags, needs: "dev", mode: noSync ? "read · local preview" : "draft" });
|
|
2074
2573
|
const creds = target.dev;
|
|
2075
2574
|
const port = Number(flags.port) || 3030;
|
|
2076
2575
|
// #989: `--instance <handle>` names the draft to sync into, for a site where the platform refuses to pick one. Draft
|
|
2077
2576
|
// sync never writes the live theme, so the live theme's handle is refused here.
|
|
2078
|
-
const devInstance = typeof flags.instance === "string" ? flags.instance :
|
|
2577
|
+
const devInstance = typeof flags.instance === "string" ? flags.instance : savedInstance;
|
|
2079
2578
|
if (devInstance && instanceMaybeLive(devInstance, target.identity?.liveThemeId ?? null)) {
|
|
2080
2579
|
console.error(`✗ ${devInstance} is (or could not be told apart from) the LIVE theme. 'theme dev' syncs to a draft only; pick a draft handle (\`blocofy status\`) or use --no-sync.`);
|
|
2081
2580
|
process.exit(2);
|
|
@@ -2151,7 +2650,8 @@ async function themeDev(rest) {
|
|
|
2151
2650
|
const handle = startDevServer({
|
|
2152
2651
|
dir: themeDir,
|
|
2153
2652
|
url: creds.url,
|
|
2154
|
-
token:
|
|
2653
|
+
// A CLI login's access token lives 10 minutes: the server asks for a fresh one per request.
|
|
2654
|
+
token: target.renewToken ?? creds.token,
|
|
2155
2655
|
port,
|
|
2156
2656
|
// Taslak senkronu dev session'a BAĞLI DEĞİL: `pushTheme({draft:true})` /api/dev/theme'e gider ve
|
|
2157
2657
|
// session'dan hiçbir veri kullanmaz. Bu satır `Boolean(session)` iken, session 410 alınca senkron
|
|
@@ -2271,6 +2771,8 @@ async function themePublish(rest) {
|
|
|
2271
2771
|
details: { reason: error.reason ?? null, instance: error.instance ?? null },
|
|
2272
2772
|
});
|
|
2273
2773
|
}
|
|
2774
|
+
// A theme work's copy is never published directly; its way live is the approval (`theme work request-approval`).
|
|
2775
|
+
if (error?.code === "work_not_publishable") failOnThemeWorkRefusal(error, { op: "publish" });
|
|
2274
2776
|
throw error;
|
|
2275
2777
|
}
|
|
2276
2778
|
console.log(
|
|
@@ -2278,6 +2780,442 @@ async function themePublish(rest) {
|
|
|
2278
2780
|
);
|
|
2279
2781
|
}
|
|
2280
2782
|
|
|
2783
|
+
// ── theme work ("çalışma"): a safe, private copy of the live theme, continued by its exact handle ──────────────
|
|
2784
|
+
|
|
2785
|
+
/**
|
|
2786
|
+
* A theme-work refusal: the plain Turkish explanation first (human mode), then the shared `error [code]` line /
|
|
2787
|
+
* `--json` envelope; exit 2 for a 4xx refusal, 1 for a 5xx one. Returns only when `error` is not a known refusal.
|
|
2788
|
+
*/
|
|
2789
|
+
function failOnThemeWorkRefusal(error, opts) {
|
|
2790
|
+
const refusal = themeWorkRefusal(error, opts);
|
|
2791
|
+
if (!refusal) return;
|
|
2792
|
+
if (!JSON_MODE) for (const line of refusal.lines) console.error(line);
|
|
2793
|
+
const code = error instanceof CliRefusal ? error.error?.code : error?.code;
|
|
2794
|
+
failAndExit({ code, status: error.status, message: refusal.message, details: refusal.details });
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
/** The handle positional of status/resume/cancel — explicit, never "the latest"; the saved one is only suggested. */
|
|
2798
|
+
function workHandleArg(positionals, flags, usage) {
|
|
2799
|
+
const handle = positionals[0];
|
|
2800
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${usage}`, {}, 1);
|
|
2801
|
+
if (handle === undefined) {
|
|
2802
|
+
let saved = null;
|
|
2803
|
+
try {
|
|
2804
|
+
saved = readSavedWork(findBinding(commandDir(flags.dir)));
|
|
2805
|
+
} catch {
|
|
2806
|
+
saved = null;
|
|
2807
|
+
}
|
|
2808
|
+
const hint = saved ? ` This project's saved work is ${saved.handle}${saved.intent ? ` ("${saved.intent}")` : ""}.` : "";
|
|
2809
|
+
throw new TargetError("USAGE", `Usage: ${usage} — name the work by its handle (wk_…).${hint} Nothing was sent.`, saved ? { saved_work: saved.handle } : {}, 1);
|
|
2810
|
+
}
|
|
2811
|
+
if (!isWorkHandle(handle)) throw new TargetError("USAGE", `"${handle}" is not a work handle (wk_ + 26 characters, from \`blocofy theme work start\`). Nothing was sent.`, {}, 1);
|
|
2812
|
+
return handle;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/** `theme push --work`: the work's theme, only while the work is open. Refusals exit here. */
|
|
2816
|
+
async function openWorkTheme(api, handle) {
|
|
2817
|
+
let work;
|
|
2818
|
+
try {
|
|
2819
|
+
({ work } = await getWork({ ...api, handle, onRetry }));
|
|
2820
|
+
} catch (error) {
|
|
2821
|
+
failOnThemeWorkRefusal(error, { op: "push", handle });
|
|
2822
|
+
throw error;
|
|
2823
|
+
}
|
|
2824
|
+
if (work.state !== "open" || !work.theme) {
|
|
2825
|
+
if (!JSON_MODE) {
|
|
2826
|
+
console.error(`Çalışma ${handle} şu an değiştirilemez (durum: ${stateLabel(work.state)}). Hiçbir şey yazılmadı.`);
|
|
2827
|
+
console.error(work.state === "sealed" ? " İnceleme bitince ya da panelde düzenlemeye geri alınınca tekrar dene." : " Yeni bir çalışma başlat: blocofy theme work start");
|
|
2828
|
+
}
|
|
2829
|
+
failAndExit({ code: "work_state_conflict", status: 409, message: `The work is ${work.state}; a push needs an open work. Nothing was written.`, details: { state: work.state, stateVersion: work.state_version } });
|
|
2830
|
+
}
|
|
2831
|
+
if (!JSON_MODE) for (const line of staleLines(work) ?? []) console.error(line);
|
|
2832
|
+
return work;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
async function themeWorkStart(rest) {
|
|
2836
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkStart);
|
|
2837
|
+
const dir = singleDirArg(positionals, 'blocofy theme work start [dir] [--intent "<text>"] [--idempotency-key <k>]');
|
|
2838
|
+
if (flags.intent !== undefined && (typeof flags.intent !== "string" || !flags.intent.trim() || flags.intent.trim().length > WORK_INTENT_MAX)) {
|
|
2839
|
+
throw new TargetError("USAGE", `--intent needs a short label (1-${WORK_INTENT_MAX} characters). Nothing was sent.`, {}, 1);
|
|
2840
|
+
}
|
|
2841
|
+
if (flags["idempotency-key"] !== undefined && (typeof flags["idempotency-key"] !== "string" || !WORK_KEY_RE.test(flags["idempotency-key"]))) {
|
|
2842
|
+
throw new TargetError("USAGE", "--idempotency-key must be 8-120 characters: letters, digits, . _ : - Nothing was sent.", {}, 1);
|
|
2843
|
+
}
|
|
2844
|
+
const intent = typeof flags.intent === "string" ? flags.intent.trim() : DEFAULT_WORK_INTENT;
|
|
2845
|
+
const key = typeof flags["idempotency-key"] === "string" ? flags["idempotency-key"] : newWorkKey();
|
|
2846
|
+
const target = await prepareTarget({ command: "theme work start", commandClass: "remote-mutation", dir, flags, needs: "api", mode: "draft · new work" });
|
|
2847
|
+
const retryLine = `blocofy theme work start${positionals[0] ? ` ${positionals[0]}` : ""} --intent ${JSON.stringify(intent)} --idempotency-key ${key}`;
|
|
2848
|
+
let answer;
|
|
2849
|
+
try {
|
|
2850
|
+
answer = await startWork({ ...target.api, intent, idempotencyKey: key, onRetry });
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
// A 4xx is a definite refusal (nothing started). Anything else (network, a 5xx after the retries) may hide a work
|
|
2853
|
+
// that WAS started: the SAME key returns it, a new key would start a second one — so the retry line names the key.
|
|
2854
|
+
const definite = error instanceof CliRefusal;
|
|
2855
|
+
const refusal = themeWorkRefusal(error, { op: "start" });
|
|
2856
|
+
if (definite && !refusal) throw error;
|
|
2857
|
+
if (!JSON_MODE) {
|
|
2858
|
+
const lines = refusal?.lines ?? ["Platformdan kesin yanıt alınamadı; çalışmanın başlayıp başlamadığı bilinmiyor."];
|
|
2859
|
+
for (const line of lines) console.error(line);
|
|
2860
|
+
if (!definite) console.error(` Aynı anahtarla tekrar çalıştır (başladıysa aynı çalışma döner, ikincisi açılmaz): ${retryLine}`);
|
|
2861
|
+
}
|
|
2862
|
+
const code = definite ? error.error?.code : error?.code ?? (error instanceof TypeError ? "NETWORK_ERROR" : "ERROR");
|
|
2863
|
+
const message = refusal?.message ?? `No definite answer from the platform; whether the work started is unknown. Run again with the same key: ${retryLine}`;
|
|
2864
|
+
failAndExit({ code, status: error.status, message, details: { ...(refusal?.details ?? {}), ...(definite ? {} : { idempotencyKey: key }) } });
|
|
2865
|
+
}
|
|
2866
|
+
const { work } = answer;
|
|
2867
|
+
const saved = saveWork(target.binding, work);
|
|
2868
|
+
if (JSON_MODE) {
|
|
2869
|
+
console.log(JSON.stringify({ work, ...(answer.replayed ? { replayed: true } : {}), idempotency_key: key, saved }, null, 2));
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
console.log(answer.replayed ? `✓ Bu anahtarla başlatılmış çalışma bulundu (yeni bir çalışma açılmadı): ${work.id}` : `✓ Çalışma başlatıldı: ${work.id}`);
|
|
2873
|
+
for (const line of workLines(work)) console.log(line);
|
|
2874
|
+
for (const line of staleLines(work) ?? []) console.log(line);
|
|
2875
|
+
console.log("Canlı site değişmedi; bu çalışma ziyaretçilere görünmez.");
|
|
2876
|
+
console.log(`Dosyalarını gönder: blocofy theme push${positionals[0] ? ` ${positionals[0]}` : ""} --draft --work ${work.id}`);
|
|
2877
|
+
console.log(`Durumu: blocofy theme work status ${work.id}`);
|
|
2878
|
+
console.log(saved ? " (Bu projede kayıtlı çalışma olarak .blocofy/local.json'a yazıldı.)" : " (Proje bağlı olmadığı için tanıtıcı kaydedilmedi; bir yere not et.)");
|
|
2879
|
+
console.log(`technical: work ${work.id}, theme ${work.theme}, idempotency key ${key}`);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
async function themeWorkStatus(rest) {
|
|
2883
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkStatus);
|
|
2884
|
+
const handle = workHandleArg(positionals, flags, "blocofy theme work status <wk_…> [--dir <dir>]");
|
|
2885
|
+
const target = await prepareTarget({ command: "theme work status", commandClass: "read", dir: commandDir(flags.dir), flags, needs: "api", mode: `read · work ${handle}` });
|
|
2886
|
+
let answer;
|
|
2887
|
+
try {
|
|
2888
|
+
answer = await getWork({ ...target.api, handle, onRetry });
|
|
2889
|
+
} catch (error) {
|
|
2890
|
+
failOnThemeWorkRefusal(error, { op: "status", handle });
|
|
2891
|
+
throw error;
|
|
2892
|
+
}
|
|
2893
|
+
// Where its publication stands (read-only). A platform without the status endpoint just shows the work.
|
|
2894
|
+
let publish = null;
|
|
2895
|
+
try {
|
|
2896
|
+
publish = await getPublishStatus({ ...target.api, handle, onRetry });
|
|
2897
|
+
} catch {
|
|
2898
|
+
publish = null;
|
|
2899
|
+
}
|
|
2900
|
+
if (JSON_MODE) {
|
|
2901
|
+
console.log(JSON.stringify({ work: answer.work, ...(publish ? { publish } : {}) }, null, 2));
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
for (const line of workLines(answer.work)) console.log(line);
|
|
2905
|
+
for (const line of publishStatusLines(publish)) console.log(line);
|
|
2906
|
+
for (const line of staleLines(answer.work) ?? []) console.log(line);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/** A 404/405 from a review endpoint right after the work was read means the platform has no such endpoint yet. */
|
|
2910
|
+
const endpointMissing = (error) =>
|
|
2911
|
+
error instanceof CliRefusal && ["not_found", "http_404", "http_405", "method_not_allowed"].includes(error.error?.code) && error.error?.details?.reason !== "target_deleted";
|
|
2912
|
+
|
|
2913
|
+
async function themeWorkSeal(rest) {
|
|
2914
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkSeal);
|
|
2915
|
+
const handle = workHandleArg(positionals, flags, "blocofy theme work seal <wk_…> [--dir <dir>]");
|
|
2916
|
+
const target = await prepareTarget({ command: "theme work seal", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "api", mode: `draft · prepare work ${handle} for review` });
|
|
2917
|
+
try {
|
|
2918
|
+
await getWork({ ...target.api, handle, onRetry });
|
|
2919
|
+
} catch (error) {
|
|
2920
|
+
failOnThemeWorkRefusal(error, { op: "seal", handle });
|
|
2921
|
+
throw error;
|
|
2922
|
+
}
|
|
2923
|
+
let answer;
|
|
2924
|
+
try {
|
|
2925
|
+
answer = await sealWork({ ...target.api, handle, onRetry });
|
|
2926
|
+
} catch (error) {
|
|
2927
|
+
if (endpointMissing(error)) {
|
|
2928
|
+
if (!JSON_MODE) {
|
|
2929
|
+
console.error("Bu platform ayrı bir 'incelemeye hazırla' adımını henüz desteklemiyor. Hiçbir şey yazılmadı.");
|
|
2930
|
+
console.error(` Onay isteği çalışmayı kendisi hazırlar: blocofy theme work request-approval ${handle}`);
|
|
2931
|
+
}
|
|
2932
|
+
failAndExit({ code: "seal_unsupported", status: error.status, message: "This platform has no seal endpoint yet; request-approval prepares the work itself (seal_unsupported)." });
|
|
2933
|
+
}
|
|
2934
|
+
failOnThemeWorkRefusal(error, { op: "seal", handle });
|
|
2935
|
+
throw error;
|
|
2936
|
+
}
|
|
2937
|
+
if (JSON_MODE) {
|
|
2938
|
+
console.log(JSON.stringify(answer, null, 2));
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
console.log(answer.already_sealed ? `✓ Çalışma zaten incelemeye hazır: ${handle}` : `✓ Çalışma incelemeye hazır: ${handle}`);
|
|
2942
|
+
console.log(" İçeriği artık değişmiyor. Canlı site değişmedi; onay istenmedi.");
|
|
2943
|
+
if (answer.package?.digest_short) console.log(` İçerik özeti: ${answer.package.digest_short}`);
|
|
2944
|
+
console.log(`Yayın için onay iste: blocofy theme work request-approval ${handle}`);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2948
|
+
|
|
2949
|
+
/**
|
|
2950
|
+
* `--wait`: poll the read-only status until the request this command made ends (published, stale, expired, declined,
|
|
2951
|
+
* superseded, cancelled, failed) or a deadline passes (the request's expiry + 1 minute, at most 20 minutes). A
|
|
2952
|
+
* transient error keeps polling; a refusal ends the wait. Never publishes and never decides.
|
|
2953
|
+
*/
|
|
2954
|
+
async function waitForApproval(api, handle, approval, intervalMs) {
|
|
2955
|
+
const expiry = Date.parse(approval.expires_at ?? "");
|
|
2956
|
+
const cap = Date.now() + 20 * 60_000;
|
|
2957
|
+
const deadline = Number.isFinite(expiry) ? Math.min(cap, expiry + 60_000) : cap;
|
|
2958
|
+
let last = null;
|
|
2959
|
+
for (;;) {
|
|
2960
|
+
try {
|
|
2961
|
+
last = await getPublishStatus({ ...api, handle, onRetry });
|
|
2962
|
+
const state = approvalWaitState(last, approval.id);
|
|
2963
|
+
if (state !== "pending") return { state, status: last };
|
|
2964
|
+
} catch (error) {
|
|
2965
|
+
if (error instanceof CliRefusal) {
|
|
2966
|
+
failOnThemeWorkRefusal(error, { op: "wait", handle });
|
|
2967
|
+
throw error;
|
|
2968
|
+
}
|
|
2969
|
+
if (!JSON_MODE) console.error("Durum okunamadı; tekrar denenecek.");
|
|
2970
|
+
}
|
|
2971
|
+
if (Date.now() + intervalMs > deadline) return { state: "timeout", status: last };
|
|
2972
|
+
await sleep(intervalMs);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
async function themeWorkRequestApproval(rest) {
|
|
2977
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkRequestApproval);
|
|
2978
|
+
const handle = workHandleArg(positionals, flags, "blocofy theme work request-approval <wk_…> [--dir <dir>] [--open] [--wait [--interval <s>]]");
|
|
2979
|
+
let intervalMs = 3000;
|
|
2980
|
+
if (flags.interval !== undefined) {
|
|
2981
|
+
const n = Number(flags.interval);
|
|
2982
|
+
if (!flags.wait || typeof flags.interval !== "string" || !Number.isInteger(n) || n < 1 || n > 60) {
|
|
2983
|
+
throw new TargetError("USAGE", "--interval needs --wait and a whole number of seconds (1-60). Nothing was sent.", {}, 1);
|
|
2984
|
+
}
|
|
2985
|
+
intervalMs = n * 1000;
|
|
2986
|
+
}
|
|
2987
|
+
const target = await prepareTarget({ command: "theme work request-approval", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "api", mode: `draft · request approval for work ${handle}` });
|
|
2988
|
+
let answer;
|
|
2989
|
+
try {
|
|
2990
|
+
answer = await requestApproval({ ...target.api, handle, onRetry });
|
|
2991
|
+
} catch (error) {
|
|
2992
|
+
failOnThemeWorkRefusal(error, { op: "request-approval", handle });
|
|
2993
|
+
throw error;
|
|
2994
|
+
}
|
|
2995
|
+
const { approval } = answer;
|
|
2996
|
+
const url = safeApprovalUrl(approval?.approval_url, target.api.apiUrl);
|
|
2997
|
+
if (!JSON_MODE) {
|
|
2998
|
+
console.log(`✓ Çalışma incelemeye hazır ve onay istendi: ${handle}`);
|
|
2999
|
+
console.log(`Onay URL'i: ${url ? (process.stdout.isTTY ? hyperlink(url) : url) : approval?.approval_url ?? "(yok)"}`);
|
|
3000
|
+
console.log(" Site sahibi ya da tema yetkisi olan bir ekip üyesi Blocofy'de oturum açıp bu sayfadan onaylar.");
|
|
3001
|
+
console.log(" URL'de gizli bir anahtar yok; URL'e sahip olmak yayın yetkisi vermez.");
|
|
3002
|
+
if (approval?.digest_short) console.log(` Onaylanacak içerik özeti: ${approval.digest_short}${approval.expires_at ? ` · son geçerlilik: ${approval.expires_at}` : ""}`);
|
|
3003
|
+
console.log(" Onaylanana kadar canlı site değişmez. Bu komut yayınlamaz.");
|
|
3004
|
+
}
|
|
3005
|
+
if (flags.open) {
|
|
3006
|
+
if (url) {
|
|
3007
|
+
openUrl(url);
|
|
3008
|
+
if (!JSON_MODE) console.log("Onay sayfası tarayıcıda açılıyor.");
|
|
3009
|
+
} else if (!JSON_MODE) {
|
|
3010
|
+
console.error("Onay URL'i bu platformun onay sayfası gibi görünmüyor; tarayıcıda açılmadı.");
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
if (!flags.wait) {
|
|
3014
|
+
if (JSON_MODE) console.log(JSON.stringify(answer, null, 2));
|
|
3015
|
+
else console.log(`Durumu izle: blocofy theme work status ${handle} (ya da --wait ile bekle)`);
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
if (!JSON_MODE) console.log("Karar bekleniyor (Ctrl+C ile çıkabilirsin; onay isteği geçerli kalır)…");
|
|
3019
|
+
const { state, status } = await waitForApproval(target.api, handle, approval, intervalMs);
|
|
3020
|
+
const outcome = approvalOutcome(state, { handle, status });
|
|
3021
|
+
if (outcome.ok) {
|
|
3022
|
+
if (JSON_MODE) console.log(JSON.stringify({ ...answer, outcome: state, publish: status }, null, 2));
|
|
3023
|
+
else for (const line of outcome.lines) console.log(line);
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
if (!JSON_MODE) for (const line of outcome.lines) console.error(line);
|
|
3027
|
+
// A definite "not published" end is a refusal (exit 2); a timeout or a failure is not (exit 1).
|
|
3028
|
+
const definite = !["timeout", "failed"].includes(state);
|
|
3029
|
+
failAndExit({ code: outcome.code, ...(definite ? { status: 409 } : {}), message: outcome.message, details: { approval: approval?.id ?? null, phase: status?.phase ?? null } });
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
async function themeWorkResume(rest) {
|
|
3033
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkResume);
|
|
3034
|
+
const handle = workHandleArg(positionals, flags, "blocofy theme work resume <wk_…> [--dir <dir>] [--require-fresh]");
|
|
3035
|
+
const target = await prepareTarget({ command: "theme work resume", commandClass: "read", dir: commandDir(flags.dir), flags, needs: "api", mode: `read · work ${handle}` });
|
|
3036
|
+
let answer;
|
|
3037
|
+
try {
|
|
3038
|
+
answer = await resumeWork({ ...target.api, handle, requireFresh: Boolean(flags["require-fresh"]), onRetry });
|
|
3039
|
+
} catch (error) {
|
|
3040
|
+
failOnThemeWorkRefusal(error, { op: "resume", handle });
|
|
3041
|
+
throw error;
|
|
3042
|
+
}
|
|
3043
|
+
const saved = saveWork(target.binding, answer.work);
|
|
3044
|
+
if (JSON_MODE) {
|
|
3045
|
+
console.log(JSON.stringify({ work: answer.work, saved }, null, 2));
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
console.log(`✓ Çalışmaya devam ediliyor: ${handle}`);
|
|
3049
|
+
for (const line of workLines(answer.work)) console.log(line);
|
|
3050
|
+
for (const line of staleLines(answer.work) ?? []) console.log(line);
|
|
3051
|
+
console.log(`Dosyalarını gönder: blocofy theme push --draft --work ${handle}`);
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
async function themeWorkCancel(rest) {
|
|
3055
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkCancel);
|
|
3056
|
+
const handle = workHandleArg(positionals, flags, "blocofy theme work cancel <wk_…> [--dir <dir>]");
|
|
3057
|
+
const target = await prepareTarget({ command: "theme work cancel", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "api", mode: `draft · cancel work ${handle}` });
|
|
3058
|
+
let work;
|
|
3059
|
+
try {
|
|
3060
|
+
({ work } = await getWork({ ...target.api, handle, onRetry }));
|
|
3061
|
+
if (work.state !== "cancelled") ({ work } = await cancelWork({ ...target.api, handle, expectedStateVersion: work.state_version, onRetry }));
|
|
3062
|
+
} catch (error) {
|
|
3063
|
+
// A resend after a lost answer meets its own earlier cancel: the work IS cancelled — report it, not a conflict.
|
|
3064
|
+
if (!(error instanceof CliRefusal && error.error?.code === "work_state_conflict" && error.error?.details?.state === "cancelled")) {
|
|
3065
|
+
failOnThemeWorkRefusal(error, { op: "cancel", handle });
|
|
3066
|
+
throw error;
|
|
3067
|
+
}
|
|
3068
|
+
work = { id: handle, state: "cancelled", state_version: error.error.details.stateVersion ?? null };
|
|
3069
|
+
}
|
|
3070
|
+
const saved = readSavedWork(target.binding);
|
|
3071
|
+
if (saved?.handle === handle) saveWork(target.binding, null);
|
|
3072
|
+
if (JSON_MODE) {
|
|
3073
|
+
console.log(JSON.stringify({ work }, null, 2));
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
console.log(`✓ Çalışma iptal edildi: ${handle}. Canlı site değişmedi; çalışmanın teması tema kitaplığında duruyor.`);
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
// ── blocofy init (ADR-0014 §5.6, wave P4) ─────────────────────────────────────────────────────────────────
|
|
3080
|
+
|
|
3081
|
+
const INIT_USAGE = "blocofy init [dir] [--site <handle|slug>] [--context <name>] [--api-url <url>] [--no-browser] [--insecure-storage]";
|
|
3082
|
+
|
|
3083
|
+
/** The context a resumed init directory recorded (`.blocofy/local.json`), if any. */
|
|
3084
|
+
function savedInitContext(dir) {
|
|
3085
|
+
try {
|
|
3086
|
+
const local = JSON.parse(readFileSync(join(dir, ".blocofy", "local.json"), "utf8"));
|
|
3087
|
+
return typeof local?.context === "string" && local.context ? local.context : null;
|
|
3088
|
+
} catch {
|
|
3089
|
+
return null;
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
/**
|
|
3094
|
+
* `blocofy init [dir]`: pre-checks without any request; then the credential (a saved context, env credentials, or a
|
|
3095
|
+
* browser login on a terminal); then the state machine of lib/init.mjs. Exit 0 only when a preview exists and the live
|
|
3096
|
+
* theme read back is the one read before; 5 when the live theme changed meanwhile; 3 for a directory/site refusal.
|
|
3097
|
+
*/
|
|
3098
|
+
async function initCommand(rest) {
|
|
3099
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.init);
|
|
3100
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${INIT_USAGE}`, {}, 1);
|
|
3101
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
3102
|
+
const siteArg = typeof flags.site === "string" ? flags.site : null;
|
|
3103
|
+
const unattended = isUnattended();
|
|
3104
|
+
|
|
3105
|
+
// 1. Pre-checks: nothing sent, nothing written.
|
|
3106
|
+
const pre = inspectInitDir(dir);
|
|
3107
|
+
if (pre.kind === "dirty") {
|
|
3108
|
+
throw new InitError("INIT_DIR_NOT_EMPTY", `${dir} is not empty and is not a Blocofy project. Nothing was written. Run \`blocofy init\` in a new, empty directory (init never overwrites files).`, {
|
|
3109
|
+
exitCode: 3,
|
|
3110
|
+
details: { dir },
|
|
3111
|
+
lines: ["Bu dizin boş değil ve bir Blocofy projesi değil; hiçbir şeyin üzerine yazılmadı.", " Boş, yeni bir dizinde çalıştır: blocofy init <yeni-dizin>"],
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
if (pre.kind === "legacy_project") {
|
|
3115
|
+
throw new InitError("INIT_ALREADY_BOUND", `${dir} is already bound to a site (by \`link\` or \`theme pull\`); init does not take it over. Nothing was written. In that project use: blocofy theme work start`, {
|
|
3116
|
+
exitCode: 3,
|
|
3117
|
+
details: { dir },
|
|
3118
|
+
lines: ["Bu dizin zaten bir siteye bağlı (link ya da theme pull ile); init onu sahiplenmez. Hiçbir şey yazılmadı.", " O projede yeni bir tema çalışması başlat: blocofy theme work start"],
|
|
3119
|
+
});
|
|
3120
|
+
}
|
|
3121
|
+
if (siteArg && pre.kind === "resume" && String(pre.init.site_id) !== siteArg && pre.project?.site_slug !== siteArg) {
|
|
3122
|
+
throw new InitError("INIT_SITE_MISMATCH", `${dir} was set up for site ${pre.project?.site_slug ?? pre.init.site_id}, not ${siteArg}. Nothing was sent or written.`, { exitCode: 3, details: { dir } });
|
|
3123
|
+
}
|
|
3124
|
+
// Unattended: never a browser, never a guessed site.
|
|
3125
|
+
if (unattended && !siteArg) {
|
|
3126
|
+
throw new InitError("INIT_SITE_REQUIRED", "In a non-interactive run, init needs the site named explicitly: --site <handle|slug> (and a saved --context or env credentials). Nothing was sent.", {
|
|
3127
|
+
exitCode: 3,
|
|
3128
|
+
lines: ["Etkileşimsiz çalıştırmada init hangi siteye kurulacağını tahmin etmez; hiçbir şey gönderilmedi.", " Siteyi açıkça ver: blocofy init <dizin> --site <site> --context <giriş>"],
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
// 2. The credential.
|
|
3133
|
+
const envCtx = envContext();
|
|
3134
|
+
let contextName = typeof flags.context === "string" ? flags.context : process.env.BLOCOFY_CONTEXT || (envCtx ? ENV_CONTEXT : null) || (pre.kind === "resume" ? savedInitContext(dir) : null);
|
|
3135
|
+
if (!contextName) {
|
|
3136
|
+
if (unattended) {
|
|
3137
|
+
throw new InitError("INIT_LOGIN_REQUIRED", "No credential for init: pass --context <name> of a saved login, or set env credentials. A non-interactive run never opens a browser. Nothing was sent.", { exitCode: 3 });
|
|
3138
|
+
}
|
|
3139
|
+
({ name: contextName } = await browserLoginAndSave({ flags, contextName: null, siteArg }));
|
|
3140
|
+
}
|
|
3141
|
+
const chosen = await resolveContext({ flagContext: contextName, envContextName: null, envCtx, getStore: () => loadStore(), binding: null });
|
|
3142
|
+
const { resolved, secrets, renewToken } = await credentialsFor(chosen);
|
|
3143
|
+
registerSecret(secrets.devToken);
|
|
3144
|
+
registerSecret(secrets.apiKey);
|
|
3145
|
+
assertCredentialTypes({ resolved, secrets });
|
|
3146
|
+
if (!(resolved.context.api && secrets.apiKey) || !(resolved.context.dev && secrets.devToken)) {
|
|
3147
|
+
throw new TargetError("LOGIN_REQUIRED", `Context "${resolved.name}" cannot run init: it needs the v1 API (theme work, preview) and the theme files endpoint. Log in with \`blocofy login\` (one login covers both) or add the missing pair.`, { context: resolved.name }, 1);
|
|
3148
|
+
}
|
|
3149
|
+
const identity = await verifyTarget({ resolved, secrets, binding: null, retry });
|
|
3150
|
+
for (const w of identity.warnings ?? []) printWarning(w, { json: JSON_MODE });
|
|
3151
|
+
if (siteArg && !siteMatches(identity.site, siteArg)) {
|
|
3152
|
+
throw new InitError("INIT_SITE_MISMATCH", `Context "${resolved.name}" is for ${identity.site.slug ?? identity.site.id}, not ${siteArg}. Nothing was sent or written.`, { exitCode: 3, details: { context: resolved.name, site: identity.site.id } });
|
|
3153
|
+
}
|
|
3154
|
+
if (!JSON_MODE) printTarget(targetData({ site: identity.site, url: resolved.context.api.url, platformOrigin: identity.platformOrigin, contextName: resolved.name, contextSource: chosen.source, contextOverrides: [], bindingLabel: pre.kind === "resume" ? "this init directory" : "none (new init)", command: "init", mode: "draft · new work" }));
|
|
3155
|
+
|
|
3156
|
+
// 3. The state machine.
|
|
3157
|
+
const api = initApi({
|
|
3158
|
+
apiUrl: resolved.context.api.url,
|
|
3159
|
+
devUrl: resolved.context.dev.url,
|
|
3160
|
+
token: renewToken ?? secrets.apiKey,
|
|
3161
|
+
devToken: renewToken ?? secrets.devToken,
|
|
3162
|
+
onRetry,
|
|
3163
|
+
});
|
|
3164
|
+
const result = await runInit({
|
|
3165
|
+
dir,
|
|
3166
|
+
identity,
|
|
3167
|
+
contextName: resolved.env ? ENV_CONTEXT : resolved.name,
|
|
3168
|
+
profile: resolved.context.oauth?.profile ?? null,
|
|
3169
|
+
api,
|
|
3170
|
+
log: (line) => {
|
|
3171
|
+
if (!JSON_MODE) console.error(line);
|
|
3172
|
+
},
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
const rel = relative(process.cwd(), dir) || ".";
|
|
3176
|
+
if (!result.liveUnchanged) {
|
|
3177
|
+
throw new InitError("INIT_LIVE_CHANGED", `The live theme read back after init (${result.liveAfter ?? "none"}) is not the one read before it started (${result.liveBefore ?? "none"}). init never writes the live site; the change came from elsewhere. Not reported as a success.`, {
|
|
3178
|
+
exitCode: 5,
|
|
3179
|
+
details: { live_before: result.liveBefore, live_after: result.liveAfter, work: result.work.id },
|
|
3180
|
+
lines: [
|
|
3181
|
+
`Canlı tema init başladığından beri değişti (önce: ${result.liveBefore ?? "yok"}, şimdi: ${result.liveAfter ?? "yok"}). Bu yüzden başarı denmedi.`,
|
|
3182
|
+
" init canlı siteye yazmaz; değişiklik panelden ya da başka bir bağlantıdan gelmiş olabilir. Panelde tema geçmişine bak.",
|
|
3183
|
+
` Çalışman ve dosyaların yerinde: ${result.work.id} → ${rel}`,
|
|
3184
|
+
],
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
if (result.state !== "previewed") {
|
|
3188
|
+
throw new InitError("INIT_PREVIEW_UNAVAILABLE", `The project is set up (work ${result.work.id}, files in ${rel}) and the live site did not change, but no preview link could be made (${result.preview}: the work's theme has no page with a plain address). Run \`blocofy init\` again to retry the preview.`, {
|
|
3189
|
+
exitCode: 1,
|
|
3190
|
+
details: { work: result.work.id, preview: result.preview },
|
|
3191
|
+
lines: [
|
|
3192
|
+
"Proje hazır ve canlı siten değişmedi, ama önizleme bağlantısı oluşturulamadı: çalışmanın temasında adresi olan bir sayfa yok.",
|
|
3193
|
+
` Aynı dizinde yeniden çalıştırınca yalnız önizleme denenir: blocofy init ${rel}`,
|
|
3194
|
+
],
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
if (JSON_MODE) {
|
|
3198
|
+
console.log(JSON.stringify({ init: { dir, state: result.state, work: { id: result.work.id, theme: result.work.theme }, preview_url: result.previewUrl, preview_created_earlier: result.previewCreatedEarlier, live_unchanged: true, live_theme: result.liveAfter, context: resolved.name, site: identity.site } }, null, 2));
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
console.log("✓ Taslak hazır. Canlı siten değişmedi.");
|
|
3202
|
+
console.log(` Çalışma: ${result.work.id} (tema ${result.work.theme}) — dosyalar: ${rel}`);
|
|
3203
|
+
if (result.previewUrl) console.log(` Önizleme (bir kez gösterilir; paylaşılabilir, süresi dolar): ${result.previewUrl}`);
|
|
3204
|
+
else console.log(" Önizleme bağlantısı daha önce oluşturuldu (bağlantılar bir kez gösterilir).");
|
|
3205
|
+
console.log(`Sonraki adım: dosyaları düzenle, sonra blocofy theme push ${rel} --draft --work ${result.work.id}`);
|
|
3206
|
+
console.log(` Yayın için insan onayı iste: blocofy theme work request-approval ${result.work.id}`);
|
|
3207
|
+
console.log(`technical: live theme ${result.liveAfter ?? "none"} read before and after; state in ${join(rel, ".blocofy", "init.json")}`);
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
const THEME_WORK_USAGE = "blocofy theme work start|status|resume|seal|request-approval|cancel … (see `blocofy --help`)";
|
|
3211
|
+
|
|
3212
|
+
async function themeWork(rest) {
|
|
3213
|
+
const [sub, ...subRest] = rest;
|
|
3214
|
+
const handler = { start: themeWorkStart, status: themeWorkStatus, resume: themeWorkResume, seal: themeWorkSeal, "request-approval": themeWorkRequestApproval, cancel: themeWorkCancel }[sub];
|
|
3215
|
+
if (!handler) throw new TargetError("USAGE", `Usage: ${THEME_WORK_USAGE}`, {}, 1);
|
|
3216
|
+
return handler(subRest);
|
|
3217
|
+
}
|
|
3218
|
+
|
|
2281
3219
|
async function themeRename(rest) {
|
|
2282
3220
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeRename);
|
|
2283
3221
|
const handle = positionals[0];
|
|
@@ -2484,6 +3422,7 @@ function commandKey(a, b) {
|
|
|
2484
3422
|
// command → handler. Every failure exits through failAndExit (exit codes 1/2/3, --json envelope last).
|
|
2485
3423
|
const COMMANDS = {
|
|
2486
3424
|
login: login,
|
|
3425
|
+
init: initCommand,
|
|
2487
3426
|
contexts: contextsCommand,
|
|
2488
3427
|
use: useCommand,
|
|
2489
3428
|
logout: logoutCommand,
|
|
@@ -2495,6 +3434,7 @@ const COMMANDS = {
|
|
|
2495
3434
|
"theme push": themePush,
|
|
2496
3435
|
"theme publish": themePublish,
|
|
2497
3436
|
"theme rename": themeRename,
|
|
3437
|
+
"theme work": themeWork,
|
|
2498
3438
|
"pages media-uses": pagesMediaUses,
|
|
2499
3439
|
"pages media-decide": pagesMediaDecide,
|
|
2500
3440
|
"pages pull": pagesPull,
|