@blocofy/cli 0.15.0 → 0.17.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 +122 -13
- package/bin/blocofy.mjs +621 -43
- package/lib/args.mjs +1 -1
- 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 +2 -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,
|
|
@@ -87,12 +94,14 @@ const args = process.argv.slice(2);
|
|
|
87
94
|
// 0.8.0: `login --api-key` DEĞERSİZ bayraktır (parser'da boolean) — sır gizli prompt'tan ya da
|
|
88
95
|
// BLOCOFY_API_KEY'den gelir, argv'ye asla girmez. `pages media-uses` / `media-decide` v1 API komutlarıdır.
|
|
89
96
|
const KNOWN = {
|
|
90
|
-
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"],
|
|
91
99
|
pages: ["decisions", "expected-revision-id", "expected-version", "json", "dir"],
|
|
92
100
|
themePull: ["draft", "instance"],
|
|
93
101
|
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune", "work"],
|
|
94
102
|
themeDev: ["port", "dry", "no-sync", "name", "instance"],
|
|
95
|
-
|
|
103
|
+
// `yes`/`confirm` are accepted (no-op: publish never prompts) so `--yes` works the same as on `theme push --live`.
|
|
104
|
+
themePublish: ["instance", "yes", "confirm"],
|
|
96
105
|
themeRename: ["name", "dir"],
|
|
97
106
|
themeWorkStart: ["intent", "idempotency-key"],
|
|
98
107
|
themeWorkStatus: ["dir"],
|
|
@@ -159,9 +168,23 @@ function printHelp() {
|
|
|
159
168
|
Develop your theme locally against live data, preview it three ways, and publish.
|
|
160
169
|
|
|
161
170
|
Usage
|
|
162
|
-
blocofy login [--context <name>] [--
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
blocofy login [--context <name>] [--site <handle|slug>] [--api-url <url>] [--no-browser]
|
|
172
|
+
[--insecure-storage]
|
|
173
|
+
Log in with your browser (PKCE, loopback 127.0.0.1/[::1] callback): pick the site and
|
|
174
|
+
approve. The login is draft-only ("Tema geliştirme"): it can never change the live site or
|
|
175
|
+
publish. "Connected" is printed only after GET /api/v1/ping confirms site + profile. The
|
|
176
|
+
tokens go to the OS secure store (macOS Keychain, Windows DPAPI file, Linux Secret Service);
|
|
177
|
+
no store → refused, never a silent plain file. --insecure-storage (or
|
|
178
|
+
BLOCOFY_SECRET_STORE=file) chooses ~/.blocofy/secrets.json (0600) explicitly.
|
|
179
|
+
Needs a terminal: CI / SSH never opens a browser (use env credentials or the options below).
|
|
180
|
+
--api-url <url> platform (default https://app.blocofy.com)
|
|
181
|
+
--no-browser do not open the browser; open the printed URL yourself
|
|
182
|
+
--site refuse (and revoke) the login unless this site was approved
|
|
183
|
+
|
|
184
|
+
blocofy login --url <url> --token [--context <name>] [--keychain] (advanced)
|
|
185
|
+
Verify a pasted dev token (bcf_… / bcf2_…) against its site (GET /api/dev/whoami) and save
|
|
186
|
+
it as a named context (default name: the site's slug). The token is read from a hidden
|
|
187
|
+
prompt; --token <value> works but warns (argv is visible in ps and shell history).
|
|
165
188
|
Get a token from the admin panel → Settings → Theme CLI tokens.
|
|
166
189
|
--keychain keep the secret in the macOS keychain (or BLOCOFY_SECRET_STORE=keychain);
|
|
167
190
|
default: ~/.blocofy/secrets.json (0600)
|
|
@@ -176,7 +199,18 @@ Usage
|
|
|
176
199
|
blocofy contexts [--json] list saved contexts (never prints secrets)
|
|
177
200
|
blocofy use <name> default context for status / target / pages check outside a
|
|
178
201
|
project (never used by any other command)
|
|
179
|
-
blocofy logout --context <name>
|
|
202
|
+
blocofy logout [--context <name>] remove a context and its secrets; a browser login is also
|
|
203
|
+
revoked on the platform (RFC 7009). Both results are printed;
|
|
204
|
+
exit 4 if the local copy could not be removed or the platform
|
|
205
|
+
login was NOT revoked (then cut it in the panel).
|
|
206
|
+
|
|
207
|
+
blocofy init [dir] [--site <handle|slug>] [--context <name>]
|
|
208
|
+
Set up a new project: (browser login if needed) → a theme work of its own (idempotent:
|
|
209
|
+
.blocofy/init.json keeps its key; re-run continues, never a second work) → the work's theme
|
|
210
|
+
files → .blocofy/project.json → a preview link → the live theme read back unchanged.
|
|
211
|
+
Refuses a non-empty directory that is not a project and one bound to another site
|
|
212
|
+
(exit 3, nothing written). Non-interactive runs need --site and a saved/env credential.
|
|
213
|
+
Exit 5: the live theme changed meanwhile (init never writes it; not reported as success).
|
|
180
214
|
blocofy link [dir] --context <name> [--adopt]
|
|
181
215
|
Bind a project directory to the context's (verified) site: writes .blocofy/project.json
|
|
182
216
|
(commit it), .blocofy/local.json (your context; git-ignored) and .blocofy/.gitignore.
|
|
@@ -435,7 +469,8 @@ Usage
|
|
|
435
469
|
blocofy --help
|
|
436
470
|
|
|
437
471
|
Examples
|
|
438
|
-
blocofy login
|
|
472
|
+
blocofy login && blocofy init my-site (browser login, new project)
|
|
473
|
+
blocofy login --url https://store.myblocofy.com --token (pasted dev token, hidden)
|
|
439
474
|
blocofy theme pull store-theme --context store && cd store-theme && blocofy theme dev
|
|
440
475
|
blocofy link ~/code/store-theme --context store (an existing checkout)
|
|
441
476
|
blocofy theme push && blocofy theme publish (inside the bound project)
|
|
@@ -468,16 +503,19 @@ Targets (which site a command talks to)
|
|
|
468
503
|
(one warning; run \`blocofy link --adopt\` to record it). A server that reports no origin cannot
|
|
469
504
|
serve a binding that records one (TARGET_UNVERIFIED).
|
|
470
505
|
Exit codes (every command): 0 ok · 1 usage/network/5xx/local check · 2 server refusal (HTTP 4xx)
|
|
471
|
-
· 3 target/binding refusal · 4 'site apply'
|
|
472
|
-
every step already applied is safe, re-run the same command to resume
|
|
506
|
+
· 3 target/binding refusal · 4 'site apply': not finished within its bounded pass count —
|
|
507
|
+
every step already applied is safe, re-run the same command to resume; 'logout': the local
|
|
508
|
+
login was not removed or the platform login was NOT revoked · 5 'init': the live theme changed
|
|
509
|
+
meanwhile (init never writes it; not reported as a success).
|
|
473
510
|
--json: every failure prints {"error":{"code","message","details"}} as the LAST stderr line; the
|
|
474
511
|
target block ({"target":…}) and any warning lines are printed on stderr before it.
|
|
475
512
|
Retries: network errors and HTTP 429/502/503/504 are retried up to 3 times (Retry-After honoured,
|
|
476
513
|
max 30s per wait; else 0.3s/0.9s/2s), resending the identical request (pages push carries one
|
|
477
514
|
x-idempotency-key per push). HTTP 500 is never retried. Each retry prints a notice on stderr.
|
|
478
515
|
|
|
479
|
-
Auth: ~/.blocofy/credentials.json (contexts, no secrets)
|
|
480
|
-
macOS
|
|
516
|
+
Auth: ~/.blocofy/credentials.json (contexts, no secrets). A browser login's tokens live in the OS
|
|
517
|
+
secure store (macOS Keychain, Windows DPAPI file, Linux Secret Service; the 0600 file only when
|
|
518
|
+
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:
|
|
481
519
|
~/.blocofy/credentials.v1.bak.json — copy it back to roll back).
|
|
482
520
|
The CLI does not build assets — bring your own (npm/Vite/Tailwind); the platform serves
|
|
483
521
|
plain Liquid + static assets.`);
|
|
@@ -509,6 +547,7 @@ const retry = { onRetry };
|
|
|
509
547
|
*/
|
|
510
548
|
function exitCodeFor(error) {
|
|
511
549
|
if (error instanceof TargetError || error instanceof CredentialsError) return error.exitCode ?? 1;
|
|
550
|
+
if (error instanceof LoginError || error instanceof InitError || error?.name === "LoopbackError") return error.exitCode ?? 1;
|
|
512
551
|
if (error instanceof CliRefusal) return 2;
|
|
513
552
|
const status = Number(error?.status);
|
|
514
553
|
return status >= 400 && status < 500 ? 2 : 1;
|
|
@@ -530,8 +569,25 @@ function envelopeFor(error) {
|
|
|
530
569
|
return { code, message: error?.message || String(error), details };
|
|
531
570
|
}
|
|
532
571
|
|
|
572
|
+
/** The context the current command resolved (for the re-login hint of a credential refusal). */
|
|
573
|
+
let activeContext = null;
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* ADR-0014 (wave P4) — the plain Turkish lines of a credential refusal (the profile gate's codes and the CLI's own
|
|
577
|
+
* REAUTH_REQUIRED), printed before the usual `error [code]` line. Never under --json (the envelope carries the code).
|
|
578
|
+
*/
|
|
579
|
+
function printRefusalLines(error) {
|
|
580
|
+
if (JSON_MODE) return;
|
|
581
|
+
if (Array.isArray(error?.lines)) for (const line of error.lines) console.error(redact(line));
|
|
582
|
+
const code = error instanceof CliRefusal ? error.error?.code : error?.details?.server_code ?? error?.code;
|
|
583
|
+
const details = error instanceof CliRefusal ? error.error?.details : error?.serverDetails ?? error?.body?.details ?? error?.details;
|
|
584
|
+
const refusal = credentialRefusal(code, details ?? {}, { context: error?.details?.context ?? activeContext });
|
|
585
|
+
if (refusal) for (const line of refusal.lines) console.error(redact(line));
|
|
586
|
+
}
|
|
587
|
+
|
|
533
588
|
function failAndExit(error) {
|
|
534
589
|
const code = exitCodeFor(error);
|
|
590
|
+
printRefusalLines(error);
|
|
535
591
|
if ((error instanceof PagesCliError || error instanceof SiteStateFsError) && !JSON_MODE) {
|
|
536
592
|
if (error.diagnostics?.length) reportPageDiagnostics(error.diagnostics);
|
|
537
593
|
}
|
|
@@ -643,6 +699,9 @@ function saveVerifiedPair(name, kind, args) {
|
|
|
643
699
|
function saveVerifiedPairLocked(name, kind, { url, secret, identity, storeName }) {
|
|
644
700
|
const store = loadStore();
|
|
645
701
|
const existing = store.contexts[name];
|
|
702
|
+
if (existing?.oauth) {
|
|
703
|
+
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 });
|
|
704
|
+
}
|
|
646
705
|
writeSecret(name, kind, storeName, secret);
|
|
647
706
|
if (existing?.[kind] && existing[kind].secret.store !== storeName) {
|
|
648
707
|
try {
|
|
@@ -684,7 +743,7 @@ async function loginApiKey(flags, positionals) {
|
|
|
684
743
|
|
|
685
744
|
let apiKey = null;
|
|
686
745
|
if (process.stdin.isTTY) {
|
|
687
|
-
apiKey = await promptSecret("API key (blcf_live_…, hidden): ");
|
|
746
|
+
apiKey = await promptSecret("API key (blcf_live_… / blcf_k2_…, hidden): ");
|
|
688
747
|
if (apiKey === null) {
|
|
689
748
|
console.error("Cancelled. Nothing was written.");
|
|
690
749
|
process.exit(1);
|
|
@@ -697,7 +756,7 @@ async function loginApiKey(flags, positionals) {
|
|
|
697
756
|
process.exit(1);
|
|
698
757
|
}
|
|
699
758
|
if (!isValidApiKey(apiKey)) {
|
|
700
|
-
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.");
|
|
759
|
+
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.");
|
|
701
760
|
process.exit(1);
|
|
702
761
|
}
|
|
703
762
|
registerSecret(apiKey);
|
|
@@ -708,22 +767,198 @@ async function loginApiKey(flags, positionals) {
|
|
|
708
767
|
await assertPairFitsContext(name, loadStore().contexts[name], identity, "dev");
|
|
709
768
|
saveVerifiedPair(name, "api", { url: apiUrl, secret: apiKey, identity, storeName });
|
|
710
769
|
console.log(`✓ API key saved to context "${name}" → ${credentialsPath()} (API: ${apiUrl})`);
|
|
770
|
+
console.log(` Secret store: ${storeLabel(storeName)}`);
|
|
711
771
|
console.log(` Site: ${siteLabel(identity.site) || identity.site.id}`);
|
|
712
772
|
console.log("Next: blocofy pages media-uses <page-handle>");
|
|
713
773
|
}
|
|
714
774
|
|
|
775
|
+
// ── browser login (ADR-0014 §5, wave P4) ──────────────────────────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
/** Where the owner manages connections (the "Connections and keys" hub). */
|
|
778
|
+
const CONNECTIONS_PATH = "/settings/connections";
|
|
779
|
+
|
|
780
|
+
/** A person at a terminal: stdin is a TTY and CI is not set. Unattended runs never open a browser. */
|
|
781
|
+
const isUnattended = () => !process.stdin.isTTY || Boolean(process.env.CI);
|
|
782
|
+
const isSshSession = () => Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
|
|
783
|
+
|
|
784
|
+
const withLines = (error, lines) => Object.assign(error, { lines });
|
|
785
|
+
|
|
786
|
+
function assertBrowserLoginPossible() {
|
|
787
|
+
if (isUnattended()) {
|
|
788
|
+
throw withLines(
|
|
789
|
+
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."),
|
|
790
|
+
[
|
|
791
|
+
"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.",
|
|
792
|
+
" CI/otomasyon: BLOCOFY_API_KEY + BLOCOFY_API_URL (ya da BLOCOFY_URL + BLOCOFY_TOKEN) ortam değişkenleri.",
|
|
793
|
+
" Gelişmiş: yapıştırılan token ile giriş: blocofy login --token ya da blocofy login --api-key",
|
|
794
|
+
],
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
if (isSshSession()) {
|
|
798
|
+
throw withLines(
|
|
799
|
+
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."),
|
|
800
|
+
[
|
|
801
|
+
"Uzak (SSH) oturumda tarayıcıyla giriş desteklenmiyor: tarayıcı bu makinenin 127.0.0.1 adresine dönemez. Hiçbir şey gönderilmedi.",
|
|
802
|
+
" Gelişmiş: yapıştırılan token ile giriş: blocofy login --token ya da blocofy login --api-key",
|
|
803
|
+
],
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function loginOrigin(flags) {
|
|
809
|
+
const origin = normalizeUrl(typeof flags["api-url"] === "string" ? flags["api-url"] : DEFAULT_API_URL);
|
|
810
|
+
if (!isValidUrl(origin)) throw new LoginError("USAGE", "Invalid --api-url — must be a valid http(s):// URL. Nothing was written.");
|
|
811
|
+
return origin;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** `--site <handle|slug>` names the approved site. */
|
|
815
|
+
const siteMatches = (site, arg) => String(site.id) === String(arg) || (site.slug != null && site.slug === arg);
|
|
816
|
+
|
|
817
|
+
/** A browser login owns its context: never one that holds pasted secrets, never one recorded for another site. */
|
|
818
|
+
function assertContextFreeForLogin(name, identity) {
|
|
819
|
+
const ctx = loadStore().contexts[name];
|
|
820
|
+
if (!ctx) return;
|
|
821
|
+
if (ctx.dev || ctx.api) {
|
|
822
|
+
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 });
|
|
823
|
+
}
|
|
824
|
+
if (identity && ctx.site) {
|
|
825
|
+
const o = compareOrigin(ctx.platform_origin, identity.platformOrigin);
|
|
826
|
+
if (String(ctx.site.id) !== String(identity.site.id) || o === "mismatch" || o === "unproven") {
|
|
827
|
+
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 });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Save a verified browser login (token set first, then the context). Returns the previous token set, if any. */
|
|
833
|
+
function saveOAuthContext(name, storeName, { tokens, identity, metadata }, origin) {
|
|
834
|
+
return withStoreLock(() => {
|
|
835
|
+
const store = loadStore();
|
|
836
|
+
const existing = store.contexts[name];
|
|
837
|
+
let previous = null;
|
|
838
|
+
if (existing?.oauth) {
|
|
839
|
+
try {
|
|
840
|
+
const t = readOAuthTokens(name, existing);
|
|
841
|
+
if (t) previous = { token: t.refresh_token ?? t.access_token, endpoint: existing.oauth.revocation_endpoint ?? null };
|
|
842
|
+
} catch {
|
|
843
|
+
previous = null;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
writeOAuthTokens(name, storeName, tokens);
|
|
847
|
+
if (existing?.oauth && existing.oauth.secret.store !== storeName) {
|
|
848
|
+
try {
|
|
849
|
+
removeSecrets(name, { oauth: existing.oauth });
|
|
850
|
+
} catch {
|
|
851
|
+
/* the old copy stays in the other store; the context now points at the new one */
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
store.contexts[name] = {
|
|
855
|
+
platform_origin: identity.platformOrigin ?? origin,
|
|
856
|
+
site: { id: identity.site.id, slug: identity.site.slug ?? null, name: identity.site.name ?? null, domain: identity.site.domain ?? null },
|
|
857
|
+
oauth: {
|
|
858
|
+
url: origin,
|
|
859
|
+
issuer: metadata.issuer,
|
|
860
|
+
client_id: CLI_CLIENT_ID,
|
|
861
|
+
token_endpoint: metadata.token_endpoint,
|
|
862
|
+
revocation_endpoint: metadata.revocation_endpoint ?? null,
|
|
863
|
+
dev_url: identity.devUrl ?? null,
|
|
864
|
+
profile: identity.profile,
|
|
865
|
+
secret: { store: storeName },
|
|
866
|
+
logged_in_at: new Date().toISOString(),
|
|
867
|
+
},
|
|
868
|
+
verified_at: new Date().toISOString(),
|
|
869
|
+
};
|
|
870
|
+
if (!store.current_context) store.current_context = name;
|
|
871
|
+
saveStore(store);
|
|
872
|
+
return previous;
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* The browser login and its save (ADR §5.2): used by `login` and by `init` when it has no credential. Success is the
|
|
878
|
+
* canonical identity probe, then the save. Returns `{ name, identity, storeName, explicitStore, origin }`.
|
|
879
|
+
*/
|
|
880
|
+
async function browserLoginAndSave({ flags, contextName = null, siteArg = null }) {
|
|
881
|
+
assertBrowserLoginPossible();
|
|
882
|
+
const origin = loginOrigin(flags);
|
|
883
|
+
let store;
|
|
884
|
+
try {
|
|
885
|
+
store = oauthSecretStore({ insecure: Boolean(flags["insecure-storage"]) });
|
|
886
|
+
} catch (error) {
|
|
887
|
+
throw error instanceof SecretStoreError ? new CredentialsError(error.code, error.message) : error;
|
|
888
|
+
}
|
|
889
|
+
if (contextName) {
|
|
890
|
+
contextNameOrExit(contextName);
|
|
891
|
+
assertContextFreeForLogin(contextName, null);
|
|
892
|
+
}
|
|
893
|
+
if (!JSON_MODE) console.error(`Blocofy girişi tarayıcıda açılıyor (${origin})…`);
|
|
894
|
+
const result = await runBrowserLogin({
|
|
895
|
+
origin,
|
|
896
|
+
openBrowser: flags["no-browser"] ? async () => {} : async (url) => openUrl(url),
|
|
897
|
+
print: (url) => {
|
|
898
|
+
if (JSON_MODE) return;
|
|
899
|
+
console.error("Tarayıcı açılmazsa bu adresi aç (adres sır içermez):");
|
|
900
|
+
console.error(` ${url}`);
|
|
901
|
+
console.error("Tarayıcıda siteni seçip onayla; en çok 5 dakika bekliyorum…");
|
|
902
|
+
},
|
|
903
|
+
onRetry,
|
|
904
|
+
});
|
|
905
|
+
registerSecret(result.tokens.access_token);
|
|
906
|
+
registerSecret(result.tokens.refresh_token);
|
|
907
|
+
const { identity } = result;
|
|
908
|
+
if (siteArg && !siteMatches(identity.site, siteArg)) {
|
|
909
|
+
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));
|
|
910
|
+
}
|
|
911
|
+
const name = contextName ?? identity.site.slug ?? "";
|
|
912
|
+
try {
|
|
913
|
+
contextNameOrExit(name);
|
|
914
|
+
assertContextFreeForLogin(name, identity);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
await result.abandon(error);
|
|
917
|
+
}
|
|
918
|
+
const previous = saveOAuthContext(name, store.name, result, origin);
|
|
919
|
+
// A re-login replaces the context's previous login: close that one too (best effort; the hub lists any survivor).
|
|
920
|
+
if (previous?.token && previous.endpoint) await revokeToken({ endpoint: previous.endpoint, token: previous.token });
|
|
921
|
+
return { name, identity, storeName: store.name, explicitStore: store.explicit, origin };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function browserLogin(flags, positionals) {
|
|
925
|
+
if (positionals.length > 0) throw new TargetError("USAGE", "Usage: blocofy login [--context <name>] [--site <handle|slug>] [--api-url <url>] [--no-browser] [--insecure-storage]", {}, 1);
|
|
926
|
+
const siteArg = typeof flags.site === "string" ? flags.site : null;
|
|
927
|
+
const { name, identity, storeName, explicitStore, origin } = await browserLoginAndSave({ flags, contextName: typeof flags.context === "string" ? flags.context : null, siteArg });
|
|
928
|
+
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 });
|
|
929
|
+
const p = identity.profile;
|
|
930
|
+
if (JSON_MODE) {
|
|
931
|
+
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));
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
console.log(`✓ Bağlandı: ${siteLabel(identity.site) || identity.site.id} — context "${name}"`);
|
|
935
|
+
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." : ""}`);
|
|
936
|
+
console.log(` Giriş bilgisi: ${storeLabel(storeName)}`);
|
|
937
|
+
if (!identity.devUrl) console.log(" Not: sitenin henüz bir alan adı yok; tema dosyası komutları (theme dev/pull/push) şimdilik çalışmaz.");
|
|
938
|
+
console.log(`Sonraki adım: yeni proje için blocofy init <dizin> · mevcut dizin için blocofy link <dizin> --context ${name}`);
|
|
939
|
+
console.log(` Bağlantıyı panelden kesmek: ${origin}${CONNECTIONS_PATH} · bu makineden çıkış: blocofy logout --context ${name}`);
|
|
940
|
+
console.log(`technical: platform ${identity.platformOrigin ?? origin}, audience ${identity.audience}, policy_version ${identity.policyVersion ?? "?"}`);
|
|
941
|
+
}
|
|
942
|
+
|
|
715
943
|
async function login(rest) {
|
|
716
944
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.login);
|
|
717
|
-
if (flags["api-key"]
|
|
718
|
-
if (!flags["api-key"]) {
|
|
719
|
-
console.error("--api-url is only used with --api-key. Nothing was written.");
|
|
720
|
-
process.exit(1);
|
|
721
|
-
}
|
|
945
|
+
if (flags["api-key"]) {
|
|
722
946
|
await loginApiKey(flags, positionals);
|
|
723
947
|
return;
|
|
724
948
|
}
|
|
949
|
+
// ADR-0014 O2: the browser is the default; a pasted dev token (`--url` / `--token` / `--keychain`) is the advanced path.
|
|
950
|
+
if (flags.url === undefined && flags.token === undefined && !flags.keychain) {
|
|
951
|
+
await browserLogin(flags, positionals);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (flags["api-url"] !== undefined || flags.site !== undefined || flags["insecure-storage"] || flags["no-browser"]) {
|
|
955
|
+
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.");
|
|
956
|
+
process.exit(1);
|
|
957
|
+
}
|
|
725
958
|
let url = typeof flags.url === "string" ? normalizeUrl(flags.url) : "";
|
|
726
959
|
let token = typeof flags.token === "string" ? flags.token.trim() : "";
|
|
960
|
+
// ADR-0014 B6: a token on the command line is visible to other local users (ps) and kept in shell history.
|
|
961
|
+
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 });
|
|
727
962
|
|
|
728
963
|
// Flag ile verildiyse anında doğrula (prompt'a düşmeden).
|
|
729
964
|
if (url && !isValidUrl(url)) {
|
|
@@ -734,7 +969,7 @@ async function login(rest) {
|
|
|
734
969
|
console.error(
|
|
735
970
|
token.startsWith("blcf_")
|
|
736
971
|
? "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."
|
|
737
|
-
: "Invalid --token — must start with bcf_.",
|
|
972
|
+
: "Invalid --token — must start with bcf_ or bcf2_.",
|
|
738
973
|
);
|
|
739
974
|
process.exit(1);
|
|
740
975
|
}
|
|
@@ -753,13 +988,30 @@ async function login(rest) {
|
|
|
753
988
|
"Enter a valid URL, e.g. https://store.myblocofy.com",
|
|
754
989
|
);
|
|
755
990
|
}
|
|
991
|
+
if (!token && process.stdin.isTTY) {
|
|
992
|
+
// ADR-0014 B6: the dev token prompt does not echo.
|
|
993
|
+
rl.close();
|
|
994
|
+
for (let i = 0; i < 3 && !token; i += 1) {
|
|
995
|
+
const answer = await promptSecret("Dev token (bcf_… / bcf2_…, hidden): ");
|
|
996
|
+
if (answer === null) {
|
|
997
|
+
console.error("Cancelled. Nothing was written.");
|
|
998
|
+
process.exit(1);
|
|
999
|
+
}
|
|
1000
|
+
if (isValidToken(answer.trim())) token = answer.trim();
|
|
1001
|
+
else console.error(" ✗ Token must start with bcf_ or bcf2_ — get one from the admin panel: Settings → Theme CLI tokens.");
|
|
1002
|
+
}
|
|
1003
|
+
if (!token) {
|
|
1004
|
+
console.error("Too many invalid attempts.");
|
|
1005
|
+
process.exit(1);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
756
1008
|
if (!token) {
|
|
757
1009
|
token = await promptValid(
|
|
758
1010
|
rl,
|
|
759
1011
|
"Dev token (bcf_…): ",
|
|
760
1012
|
(s) => s.trim(),
|
|
761
1013
|
isValidToken,
|
|
762
|
-
"Token must start with bcf_ — get one from the admin panel: Settings → Theme CLI tokens.",
|
|
1014
|
+
"Token must start with bcf_ or bcf2_ — get one from the admin panel: Settings → Theme CLI tokens.",
|
|
763
1015
|
);
|
|
764
1016
|
}
|
|
765
1017
|
} finally {
|
|
@@ -777,6 +1029,7 @@ async function login(rest) {
|
|
|
777
1029
|
saveVerifiedPair(name, "dev", { url, secret: token, identity, storeName });
|
|
778
1030
|
console.log(`✓ Saved context "${name}" → ${credentialsPath()}`);
|
|
779
1031
|
console.log(` Site: ${siteLabel(identity.site)} — commands using context "${name}" target this site.`);
|
|
1032
|
+
console.log(` Secret store: ${storeLabel(storeName)}`);
|
|
780
1033
|
console.log(`Next: bind a project directory: blocofy link <dir> --context ${name} (or pull into an empty one: blocofy theme pull <dir> --context ${name})`);
|
|
781
1034
|
}
|
|
782
1035
|
|
|
@@ -791,6 +1044,7 @@ async function contextsCommand(rest) {
|
|
|
791
1044
|
platform_origin: c.platform_origin ?? null,
|
|
792
1045
|
dev: c.dev ? { url: c.dev.url, store: c.dev.secret.store } : null,
|
|
793
1046
|
api: c.api ? { url: c.api.url, store: c.api.secret.store } : null,
|
|
1047
|
+
login: c.oauth ? { url: c.oauth.url, store: c.oauth.secret.store, profile: c.oauth.profile ?? null, state: c.oauth.state ?? "active" } : null,
|
|
794
1048
|
verified_at: c.verified_at ?? null,
|
|
795
1049
|
}));
|
|
796
1050
|
if (JSON_MODE) {
|
|
@@ -803,7 +1057,8 @@ async function contextsCommand(rest) {
|
|
|
803
1057
|
}
|
|
804
1058
|
for (const r of rows) {
|
|
805
1059
|
const site = r.site ? `${siteLabel(r.site)} · ${r.site.id}` : "(not verified yet)";
|
|
806
|
-
const
|
|
1060
|
+
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;
|
|
1061
|
+
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(", ");
|
|
807
1062
|
console.log(`${r.current ? "*" : " "} ${r.name} ${site} ${pairs}`);
|
|
808
1063
|
}
|
|
809
1064
|
}
|
|
@@ -822,20 +1077,82 @@ async function useCommand(rest) {
|
|
|
822
1077
|
console.log(" (Inside a bound project the project's site decides; `use` never retargets it.)");
|
|
823
1078
|
}
|
|
824
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* ADR-0014 §5.5 — `blocofy logout [--context <name>]`. Two independent results, both printed:
|
|
1082
|
+
* server revoked (RFC 7009 /revoke answered 200) | unreachable (network / 5xx: NOT revoked) | refused (another
|
|
1083
|
+
* answer: NOT revoked) | unavailable (the saved login could not be read: NOT revoked) | not_applicable
|
|
1084
|
+
* (a pasted token or key: revoke it in the panel)
|
|
1085
|
+
* local cleared | failed — done whatever the server said.
|
|
1086
|
+
* Exit 0 only when the local login is cleared and nothing is left open on the server; otherwise 4.
|
|
1087
|
+
*/
|
|
825
1088
|
async function logoutCommand(rest) {
|
|
826
1089
|
const { flags, positionals } = parseArgsOrExit(rest, []);
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1090
|
+
if (positionals.length) throw new TargetError("USAGE", "Usage: blocofy logout [--context <name>]", {}, 1);
|
|
1091
|
+
const store0 = loadStore();
|
|
1092
|
+
const name = typeof flags.context === "string" ? flags.context : store0.current_context;
|
|
1093
|
+
if (!name) throw new TargetError("USAGE", "Usage: blocofy logout --context <name> (no default context is set; see `blocofy contexts`).", {}, 1);
|
|
1094
|
+
const ctx = store0.contexts[name];
|
|
1095
|
+
if (!ctx) throw new TargetError("TARGET_CONTEXT_UNKNOWN", `No context named "${name}".`, { context: name });
|
|
1096
|
+
|
|
1097
|
+
let server = { outcome: "not_applicable", status: null };
|
|
1098
|
+
if (ctx.oauth) {
|
|
1099
|
+
let tokens = null;
|
|
1100
|
+
try {
|
|
1101
|
+
tokens = readOAuthTokens(name, ctx);
|
|
1102
|
+
} catch {
|
|
1103
|
+
tokens = null;
|
|
1104
|
+
}
|
|
1105
|
+
const token = tokens?.refresh_token ?? tokens?.access_token ?? null;
|
|
1106
|
+
if (token) registerSecret(token);
|
|
1107
|
+
server = token && ctx.oauth.revocation_endpoint
|
|
1108
|
+
? await revokeToken({ endpoint: ctx.oauth.revocation_endpoint, token, clientId: ctx.oauth.client_id ?? CLI_CLIENT_ID })
|
|
1109
|
+
: { outcome: "unavailable", status: null };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
let local = "cleared";
|
|
1113
|
+
let localError = null;
|
|
1114
|
+
try {
|
|
1115
|
+
withStoreLock(() => {
|
|
1116
|
+
const store = loadStore();
|
|
1117
|
+
const current = store.contexts[name];
|
|
1118
|
+
if (!current) return;
|
|
1119
|
+
removeSecrets(name, current);
|
|
1120
|
+
delete store.contexts[name];
|
|
1121
|
+
if (store.current_context === name) store.current_context = null;
|
|
1122
|
+
saveStore(store);
|
|
1123
|
+
});
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
local = "failed";
|
|
1126
|
+
localError = error;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const hub = ctx.oauth ? `${String(ctx.oauth.url).replace(/\/+$/, "")}${CONNECTIONS_PATH}` : null;
|
|
1130
|
+
const ok = local === "cleared" && (server.outcome === "revoked" || server.outcome === "not_applicable");
|
|
1131
|
+
if (JSON_MODE) {
|
|
1132
|
+
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));
|
|
1133
|
+
} else {
|
|
1134
|
+
console.log(local === "cleared" ? `✓ Yerel giriş silindi (context "${name}").` : `✗ Yerel giriş SİLİNEMEDİ (context "${name}"): ${redact(localError?.message ?? String(localError))}`);
|
|
1135
|
+
switch (server.outcome) {
|
|
1136
|
+
case "revoked":
|
|
1137
|
+
console.log("✓ Sunucudaki CLI bağlantısı iptal edildi.");
|
|
1138
|
+
break;
|
|
1139
|
+
case "not_applicable":
|
|
1140
|
+
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.");
|
|
1141
|
+
break;
|
|
1142
|
+
case "unreachable":
|
|
1143
|
+
console.log(`✗ Sunucudaki bağlantı İPTAL EDİLMEDİ (platforma ulaşılamadı${server.status ? `, HTTP ${server.status}` : ""}) — panelden kes: ${hub}`);
|
|
1144
|
+
break;
|
|
1145
|
+
case "refused":
|
|
1146
|
+
console.log(`✗ Sunucudaki bağlantı İPTAL EDİLMEDİ (platform isteği reddetti, HTTP ${server.status}) — panelden kes: ${hub}`);
|
|
1147
|
+
break;
|
|
1148
|
+
default:
|
|
1149
|
+
console.log(`✗ Sunucudaki bağlantı İPTAL EDİLMEDİ (kayıtlı giriş okunamadı) — panelden kes: ${hub}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (!ok) {
|
|
1153
|
+
if (!JSON_MODE) console.error(`logout: local ${local}, server ${server.outcome} — not complete (exit 4).`);
|
|
1154
|
+
process.exit(4);
|
|
1155
|
+
}
|
|
839
1156
|
}
|
|
840
1157
|
|
|
841
1158
|
async function linkCommand(rest) {
|
|
@@ -847,8 +1164,7 @@ async function linkCommand(rest) {
|
|
|
847
1164
|
if (!flagContext && !process.env.BLOCOFY_CONTEXT && !envCtx) {
|
|
848
1165
|
throw new TargetError("TARGET_CONTEXT_REQUIRED", "`blocofy link` needs the context to bind: pass --context <name> (see `blocofy contexts`).", {});
|
|
849
1166
|
}
|
|
850
|
-
const resolved = await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null });
|
|
851
|
-
const secrets = resolved.env ? resolved.env.secrets : readSecrets(resolved.name, resolved.context);
|
|
1167
|
+
const { resolved, secrets } = await credentialsFor(await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null }));
|
|
852
1168
|
registerSecret(secrets.devToken);
|
|
853
1169
|
registerSecret(secrets.apiKey);
|
|
854
1170
|
assertCredentialTypes({ resolved, secrets });
|
|
@@ -888,6 +1204,31 @@ async function targetCommand(rest) {
|
|
|
888
1204
|
else printTarget(t.display, { stream: process.stdout });
|
|
889
1205
|
}
|
|
890
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* ADR-0014 §5 — the secrets of a resolved context. A browser-login context answers with its (renewed) CLI token in
|
|
1209
|
+
* both places: the v1 API at its platform origin and, when the site has a domain, the renderer's `/api/dev`.
|
|
1210
|
+
* Returns `{ resolved, secrets, renewToken }` (`renewToken`: a getter for long-running commands, else null).
|
|
1211
|
+
*/
|
|
1212
|
+
async function credentialsFor(resolved) {
|
|
1213
|
+
activeContext = resolved.name;
|
|
1214
|
+
if (resolved.env) return { resolved, secrets: resolved.env.secrets, renewToken: null };
|
|
1215
|
+
const ctx = resolved.context;
|
|
1216
|
+
if (!ctx.oauth) return { resolved, secrets: readSecrets(resolved.name, ctx), renewToken: null };
|
|
1217
|
+
const access = await accessTokenFor(resolved.name);
|
|
1218
|
+
registerSecret(access);
|
|
1219
|
+
const view = { ...ctx, api: { url: ctx.oauth.url, secret: ctx.oauth.secret } };
|
|
1220
|
+
if (ctx.oauth.dev_url) view.dev = { url: ctx.oauth.dev_url, secret: ctx.oauth.secret };
|
|
1221
|
+
return {
|
|
1222
|
+
resolved: { ...resolved, context: view, oauth: true },
|
|
1223
|
+
secrets: { devToken: ctx.oauth.dev_url ? access : null, apiKey: access },
|
|
1224
|
+
renewToken: async () => {
|
|
1225
|
+
const t = await accessTokenFor(resolved.name);
|
|
1226
|
+
registerSecret(t);
|
|
1227
|
+
return t;
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
891
1232
|
/** "BLOCOFY_CONTEXT=beta, env credentials" — the stated context choices an explicit --context overrode. */
|
|
892
1233
|
function overridesLabel(ignored) {
|
|
893
1234
|
return ignored.map((a) => (a.source === "env" ? "env credentials" : `${a.source}=${a.name}`)).join(", ");
|
|
@@ -931,7 +1272,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
931
1272
|
const { newBinding } = enforceBindingPolicy({ commandClass, binding, dir, command });
|
|
932
1273
|
const envCtx = envContext();
|
|
933
1274
|
let cachedStore = null;
|
|
934
|
-
const
|
|
1275
|
+
const chosen = await resolveContext({
|
|
935
1276
|
flagContext: typeof flags.context === "string" ? flags.context : null,
|
|
936
1277
|
envContextName: process.env.BLOCOFY_CONTEXT || null,
|
|
937
1278
|
envCtx,
|
|
@@ -941,14 +1282,17 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
941
1282
|
isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
942
1283
|
prompt: promptContext,
|
|
943
1284
|
});
|
|
944
|
-
precheckContext({ binding, resolved });
|
|
945
|
-
const secrets
|
|
1285
|
+
precheckContext({ binding, resolved: chosen });
|
|
1286
|
+
const { resolved, secrets, renewToken } = await credentialsFor(chosen);
|
|
946
1287
|
registerSecret(secrets.devToken);
|
|
947
1288
|
registerSecret(secrets.apiKey);
|
|
948
1289
|
assertCredentialTypes({ resolved, secrets });
|
|
949
1290
|
|
|
950
1291
|
const hasDev = Boolean(resolved.context.dev && secrets.devToken);
|
|
951
1292
|
const hasApi = Boolean(resolved.context.api && secrets.apiKey);
|
|
1293
|
+
if (needs === "dev" && !hasDev && resolved.oauth) {
|
|
1294
|
+
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);
|
|
1295
|
+
}
|
|
952
1296
|
if (needs === "dev" && !hasDev) {
|
|
953
1297
|
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);
|
|
954
1298
|
}
|
|
@@ -985,6 +1329,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
985
1329
|
binding,
|
|
986
1330
|
newBinding,
|
|
987
1331
|
display,
|
|
1332
|
+
renewToken,
|
|
988
1333
|
};
|
|
989
1334
|
}
|
|
990
1335
|
|
|
@@ -1036,6 +1381,64 @@ async function themePull(rest) {
|
|
|
1036
1381
|
bindAfterPull(target, dir);
|
|
1037
1382
|
}
|
|
1038
1383
|
|
|
1384
|
+
// ── draft-only logins write into their own theme work (ADR-0014 §3.5, P4 follow-up) ───────────────────────
|
|
1385
|
+
|
|
1386
|
+
/** Profiles that never write a live or implicit target: their writes go to their own theme work. */
|
|
1387
|
+
const DRAFT_ONLY_PROFILES = new Set(["theme-dev", "review", "service-ci"]);
|
|
1388
|
+
|
|
1389
|
+
/** The project's saved work: `.blocofy/local.json` (theme work start / init), else `.blocofy/init.json`. */
|
|
1390
|
+
function savedWorkOf(binding) {
|
|
1391
|
+
const local = readSavedWork(binding);
|
|
1392
|
+
if (local?.handle && local.theme) return local;
|
|
1393
|
+
let init = null;
|
|
1394
|
+
try {
|
|
1395
|
+
init = readInitState(binding.root);
|
|
1396
|
+
} catch {
|
|
1397
|
+
init = null;
|
|
1398
|
+
}
|
|
1399
|
+
if (init && isWorkHandle(init.work_handle) && String(init.site_id) === String(binding.project.site_id)) {
|
|
1400
|
+
return { handle: init.work_handle, theme: typeof init.work_theme === "string" ? init.work_theme : null };
|
|
1401
|
+
}
|
|
1402
|
+
return local?.handle ? local : null;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Offline (no request): when the context this command will use carries a draft-only profile (a browser login, or a
|
|
1407
|
+
* recorded profile), `{ profile, work }` with the project's saved work (or null); otherwise null — then the command
|
|
1408
|
+
* behaves as before. Any resolution problem returns null and is reported by `prepareTarget` as usual.
|
|
1409
|
+
*/
|
|
1410
|
+
async function draftOnlyDefaults(dir, flags) {
|
|
1411
|
+
let binding;
|
|
1412
|
+
let resolved;
|
|
1413
|
+
try {
|
|
1414
|
+
binding = findBinding(dir);
|
|
1415
|
+
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 });
|
|
1416
|
+
} catch {
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
const profile = resolved.context?.oauth?.profile ?? resolved.context?.profile ?? null;
|
|
1420
|
+
if (!profile || !DRAFT_ONLY_PROFILES.has(profile.id)) return null;
|
|
1421
|
+
return { profile, work: binding ? savedWorkOf(binding) : null };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function themeWorkRequired(profile, command) {
|
|
1425
|
+
return withLines(
|
|
1426
|
+
new TargetError(
|
|
1427
|
+
"THEME_WORK_REQUIRED",
|
|
1428
|
+
`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>.`,
|
|
1429
|
+
{ profile: profile.id },
|
|
1430
|
+
3,
|
|
1431
|
+
),
|
|
1432
|
+
[
|
|
1433
|
+
`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.`,
|
|
1434
|
+
" Yeni proje: blocofy init <dizin> · bu projede yeni çalışma: blocofy theme work start",
|
|
1435
|
+
" Ya da hedefi açıkça ver: --work <wk_…> / --instance <tema>",
|
|
1436
|
+
],
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
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.`;
|
|
1441
|
+
|
|
1039
1442
|
async function themePush(rest) {
|
|
1040
1443
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePush);
|
|
1041
1444
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
@@ -1052,7 +1455,17 @@ async function themePush(rest) {
|
|
|
1052
1455
|
// Theme work: `--work <wk_…>` names the target itself — that work's own draft theme, read from the v1 API (the
|
|
1053
1456
|
// work belongs to the API key) and written through the dev token as `--draft --instance <work theme>`. Both pairs
|
|
1054
1457
|
// must resolve to the same site (prepareTarget "both"); the work must be open. Never live.
|
|
1055
|
-
|
|
1458
|
+
// A draft-only login with no explicit target writes into the project's saved work (explicit flags win).
|
|
1459
|
+
let savedWork = null;
|
|
1460
|
+
if (flags.work === undefined && !instanceFlag && !flags.live) {
|
|
1461
|
+
const d = await draftOnlyDefaults(dir, flags);
|
|
1462
|
+
if (d) {
|
|
1463
|
+
if (!d.work) throw themeWorkRequired(d.profile, "theme push");
|
|
1464
|
+
savedWork = d.work.handle;
|
|
1465
|
+
if (!JSON_MODE) console.error(savedWorkNote(d.work));
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
const workHandle = flags.work === undefined ? savedWork : flags.work;
|
|
1056
1469
|
let workTarget = null;
|
|
1057
1470
|
if (workHandle !== null) {
|
|
1058
1471
|
if (!isWorkHandle(workHandle)) throw new TargetError("USAGE", "--work needs a work handle (wk_…, from `blocofy theme work start`). Nothing was sent.", {}, 1);
|
|
@@ -2147,12 +2560,22 @@ async function themeDev(rest) {
|
|
|
2147
2560
|
|
|
2148
2561
|
// CF-T2: draft sync mutates the site (remote-mutation, binding required); `--no-sync` only renders (read).
|
|
2149
2562
|
const noSync = Boolean(flags["no-sync"]);
|
|
2563
|
+
// A draft-only login syncs into the project's saved work's theme unless --instance names another target.
|
|
2564
|
+
let savedInstance = null;
|
|
2565
|
+
if (!noSync && typeof flags.instance !== "string") {
|
|
2566
|
+
const d = await draftOnlyDefaults(themeDir, flags);
|
|
2567
|
+
if (d) {
|
|
2568
|
+
if (!d.work?.theme) throw themeWorkRequired(d.profile, "theme dev");
|
|
2569
|
+
savedInstance = d.work.theme;
|
|
2570
|
+
if (!JSON_MODE) console.error(savedWorkNote(d.work));
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2150
2573
|
const target = await prepareTarget({ command: "theme dev", commandClass: noSync ? "read" : "remote-mutation", dir: themeDir, flags, needs: "dev", mode: noSync ? "read · local preview" : "draft" });
|
|
2151
2574
|
const creds = target.dev;
|
|
2152
2575
|
const port = Number(flags.port) || 3030;
|
|
2153
2576
|
// #989: `--instance <handle>` names the draft to sync into, for a site where the platform refuses to pick one. Draft
|
|
2154
2577
|
// sync never writes the live theme, so the live theme's handle is refused here.
|
|
2155
|
-
const devInstance = typeof flags.instance === "string" ? flags.instance :
|
|
2578
|
+
const devInstance = typeof flags.instance === "string" ? flags.instance : savedInstance;
|
|
2156
2579
|
if (devInstance && instanceMaybeLive(devInstance, target.identity?.liveThemeId ?? null)) {
|
|
2157
2580
|
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.`);
|
|
2158
2581
|
process.exit(2);
|
|
@@ -2174,8 +2597,9 @@ async function themeDev(rest) {
|
|
|
2174
2597
|
}
|
|
2175
2598
|
|
|
2176
2599
|
const localUrl = `http://localhost:${port}`;
|
|
2177
|
-
|
|
2178
|
-
const
|
|
2600
|
+
// A session without a URL (an older platform answer) must not become "undefined&hr=…".
|
|
2601
|
+
const previewUrl = session?.previewUrl ? `${session.previewUrl}&hr=${port}` : null;
|
|
2602
|
+
const editorUrl = session?.editorUrl ? `${session.editorUrl}&hr=${port}` : null;
|
|
2179
2603
|
|
|
2180
2604
|
// Çözülen site'ı göster (session TOKEN'dan çözer) → hangi tenant'ı düzenlediğin belli olsun.
|
|
2181
2605
|
const siteLine = session?.site ? `${siteLabel(session.site)} · ` : "";
|
|
@@ -2228,7 +2652,8 @@ async function themeDev(rest) {
|
|
|
2228
2652
|
const handle = startDevServer({
|
|
2229
2653
|
dir: themeDir,
|
|
2230
2654
|
url: creds.url,
|
|
2231
|
-
token:
|
|
2655
|
+
// A CLI login's access token lives 10 minutes: the server asks for a fresh one per request.
|
|
2656
|
+
token: target.renewToken ?? creds.token,
|
|
2232
2657
|
port,
|
|
2233
2658
|
// Taslak senkronu dev session'a BAĞLI DEĞİL: `pushTheme({draft:true})` /api/dev/theme'e gider ve
|
|
2234
2659
|
// session'dan hiçbir veri kullanmaz. Bu satır `Boolean(session)` iken, session 410 alınca senkron
|
|
@@ -2355,6 +2780,16 @@ async function themePublish(rest) {
|
|
|
2355
2780
|
console.log(
|
|
2356
2781
|
`✓ Theme ${result.published} is now LIVE${result.cloned ? " (pages cloned from the previous live theme)" : ""}.`,
|
|
2357
2782
|
);
|
|
2783
|
+
// The platform seals the new live theme's current files on publish and says whether it did. Not sealed = the
|
|
2784
|
+
// live site may still serve the files this theme had at its last seal (an older app.js): exit 4 so scripts
|
|
2785
|
+
// notice, and say how to seal again. An older platform sends no `live_sealed`: nothing changes.
|
|
2786
|
+
if (result.live_sealed === false) {
|
|
2787
|
+
console.error(
|
|
2788
|
+
`Warning: the live site may still serve this theme's previous files (live files not refreshed: ${result.live_seal_reason ?? "unknown"}).\n` +
|
|
2789
|
+
`Refresh them with: blocofy theme push --live --yes`,
|
|
2790
|
+
);
|
|
2791
|
+
process.exitCode = 4;
|
|
2792
|
+
}
|
|
2358
2793
|
}
|
|
2359
2794
|
|
|
2360
2795
|
// ── theme work ("çalışma"): a safe, private copy of the live theme, continued by its exact handle ──────────────
|
|
@@ -2653,6 +3088,137 @@ async function themeWorkCancel(rest) {
|
|
|
2653
3088
|
console.log(`✓ Çalışma iptal edildi: ${handle}. Canlı site değişmedi; çalışmanın teması tema kitaplığında duruyor.`);
|
|
2654
3089
|
}
|
|
2655
3090
|
|
|
3091
|
+
// ── blocofy init (ADR-0014 §5.6, wave P4) ─────────────────────────────────────────────────────────────────
|
|
3092
|
+
|
|
3093
|
+
const INIT_USAGE = "blocofy init [dir] [--site <handle|slug>] [--context <name>] [--api-url <url>] [--no-browser] [--insecure-storage]";
|
|
3094
|
+
|
|
3095
|
+
/** The context a resumed init directory recorded (`.blocofy/local.json`), if any. */
|
|
3096
|
+
function savedInitContext(dir) {
|
|
3097
|
+
try {
|
|
3098
|
+
const local = JSON.parse(readFileSync(join(dir, ".blocofy", "local.json"), "utf8"));
|
|
3099
|
+
return typeof local?.context === "string" && local.context ? local.context : null;
|
|
3100
|
+
} catch {
|
|
3101
|
+
return null;
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
/**
|
|
3106
|
+
* `blocofy init [dir]`: pre-checks without any request; then the credential (a saved context, env credentials, or a
|
|
3107
|
+
* browser login on a terminal); then the state machine of lib/init.mjs. Exit 0 only when a preview exists and the live
|
|
3108
|
+
* theme read back is the one read before; 5 when the live theme changed meanwhile; 3 for a directory/site refusal.
|
|
3109
|
+
*/
|
|
3110
|
+
async function initCommand(rest) {
|
|
3111
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.init);
|
|
3112
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${INIT_USAGE}`, {}, 1);
|
|
3113
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
3114
|
+
const siteArg = typeof flags.site === "string" ? flags.site : null;
|
|
3115
|
+
const unattended = isUnattended();
|
|
3116
|
+
|
|
3117
|
+
// 1. Pre-checks: nothing sent, nothing written.
|
|
3118
|
+
const pre = inspectInitDir(dir);
|
|
3119
|
+
if (pre.kind === "dirty") {
|
|
3120
|
+
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).`, {
|
|
3121
|
+
exitCode: 3,
|
|
3122
|
+
details: { dir },
|
|
3123
|
+
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>"],
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
if (pre.kind === "legacy_project") {
|
|
3127
|
+
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`, {
|
|
3128
|
+
exitCode: 3,
|
|
3129
|
+
details: { dir },
|
|
3130
|
+
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"],
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
if (siteArg && pre.kind === "resume" && String(pre.init.site_id) !== siteArg && pre.project?.site_slug !== siteArg) {
|
|
3134
|
+
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 } });
|
|
3135
|
+
}
|
|
3136
|
+
// Unattended: never a browser, never a guessed site.
|
|
3137
|
+
if (unattended && !siteArg) {
|
|
3138
|
+
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.", {
|
|
3139
|
+
exitCode: 3,
|
|
3140
|
+
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ş>"],
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
// 2. The credential.
|
|
3145
|
+
const envCtx = envContext();
|
|
3146
|
+
let contextName = typeof flags.context === "string" ? flags.context : process.env.BLOCOFY_CONTEXT || (envCtx ? ENV_CONTEXT : null) || (pre.kind === "resume" ? savedInitContext(dir) : null);
|
|
3147
|
+
if (!contextName) {
|
|
3148
|
+
if (unattended) {
|
|
3149
|
+
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 });
|
|
3150
|
+
}
|
|
3151
|
+
({ name: contextName } = await browserLoginAndSave({ flags, contextName: null, siteArg }));
|
|
3152
|
+
}
|
|
3153
|
+
const chosen = await resolveContext({ flagContext: contextName, envContextName: null, envCtx, getStore: () => loadStore(), binding: null });
|
|
3154
|
+
const { resolved, secrets, renewToken } = await credentialsFor(chosen);
|
|
3155
|
+
registerSecret(secrets.devToken);
|
|
3156
|
+
registerSecret(secrets.apiKey);
|
|
3157
|
+
assertCredentialTypes({ resolved, secrets });
|
|
3158
|
+
if (!(resolved.context.api && secrets.apiKey) || !(resolved.context.dev && secrets.devToken)) {
|
|
3159
|
+
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);
|
|
3160
|
+
}
|
|
3161
|
+
const identity = await verifyTarget({ resolved, secrets, binding: null, retry });
|
|
3162
|
+
for (const w of identity.warnings ?? []) printWarning(w, { json: JSON_MODE });
|
|
3163
|
+
if (siteArg && !siteMatches(identity.site, siteArg)) {
|
|
3164
|
+
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 } });
|
|
3165
|
+
}
|
|
3166
|
+
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" }));
|
|
3167
|
+
|
|
3168
|
+
// 3. The state machine.
|
|
3169
|
+
const api = initApi({
|
|
3170
|
+
apiUrl: resolved.context.api.url,
|
|
3171
|
+
devUrl: resolved.context.dev.url,
|
|
3172
|
+
token: renewToken ?? secrets.apiKey,
|
|
3173
|
+
devToken: renewToken ?? secrets.devToken,
|
|
3174
|
+
onRetry,
|
|
3175
|
+
});
|
|
3176
|
+
const result = await runInit({
|
|
3177
|
+
dir,
|
|
3178
|
+
identity,
|
|
3179
|
+
contextName: resolved.env ? ENV_CONTEXT : resolved.name,
|
|
3180
|
+
profile: resolved.context.oauth?.profile ?? null,
|
|
3181
|
+
api,
|
|
3182
|
+
log: (line) => {
|
|
3183
|
+
if (!JSON_MODE) console.error(line);
|
|
3184
|
+
},
|
|
3185
|
+
});
|
|
3186
|
+
|
|
3187
|
+
const rel = relative(process.cwd(), dir) || ".";
|
|
3188
|
+
if (!result.liveUnchanged) {
|
|
3189
|
+
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.`, {
|
|
3190
|
+
exitCode: 5,
|
|
3191
|
+
details: { live_before: result.liveBefore, live_after: result.liveAfter, work: result.work.id },
|
|
3192
|
+
lines: [
|
|
3193
|
+
`Canlı tema init başladığından beri değişti (önce: ${result.liveBefore ?? "yok"}, şimdi: ${result.liveAfter ?? "yok"}). Bu yüzden başarı denmedi.`,
|
|
3194
|
+
" init canlı siteye yazmaz; değişiklik panelden ya da başka bir bağlantıdan gelmiş olabilir. Panelde tema geçmişine bak.",
|
|
3195
|
+
` Çalışman ve dosyaların yerinde: ${result.work.id} → ${rel}`,
|
|
3196
|
+
],
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
if (result.state !== "previewed") {
|
|
3200
|
+
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.`, {
|
|
3201
|
+
exitCode: 1,
|
|
3202
|
+
details: { work: result.work.id, preview: result.preview },
|
|
3203
|
+
lines: [
|
|
3204
|
+
"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.",
|
|
3205
|
+
` Aynı dizinde yeniden çalıştırınca yalnız önizleme denenir: blocofy init ${rel}`,
|
|
3206
|
+
],
|
|
3207
|
+
});
|
|
3208
|
+
}
|
|
3209
|
+
if (JSON_MODE) {
|
|
3210
|
+
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));
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
console.log("✓ Taslak hazır. Canlı siten değişmedi.");
|
|
3214
|
+
console.log(` Çalışma: ${result.work.id} (tema ${result.work.theme}) — dosyalar: ${rel}`);
|
|
3215
|
+
if (result.previewUrl) console.log(` Önizleme (bir kez gösterilir; paylaşılabilir, süresi dolar): ${result.previewUrl}`);
|
|
3216
|
+
else console.log(" Önizleme bağlantısı daha önce oluşturuldu (bağlantılar bir kez gösterilir).");
|
|
3217
|
+
console.log(`Sonraki adım: dosyaları düzenle, sonra blocofy theme push ${rel} --draft --work ${result.work.id}`);
|
|
3218
|
+
console.log(` Yayın için insan onayı iste: blocofy theme work request-approval ${result.work.id}`);
|
|
3219
|
+
console.log(`technical: live theme ${result.liveAfter ?? "none"} read before and after; state in ${join(rel, ".blocofy", "init.json")}`);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
2656
3222
|
const THEME_WORK_USAGE = "blocofy theme work start|status|resume|seal|request-approval|cancel … (see `blocofy --help`)";
|
|
2657
3223
|
|
|
2658
3224
|
async function themeWork(rest) {
|
|
@@ -2688,6 +3254,17 @@ async function status(rest) {
|
|
|
2688
3254
|
: `Live theme: none`,
|
|
2689
3255
|
);
|
|
2690
3256
|
console.log(`Health: ${s.health}`);
|
|
3257
|
+
// What live actually serves vs. the stored theme files (platforms that report it).
|
|
3258
|
+
const ls = s.live_source;
|
|
3259
|
+
if (ls?.pinned === true && ls.in_sync === false) {
|
|
3260
|
+
const paths = [...(ls.stale ?? []), ...(ls.missing ?? []), ...(ls.extra ?? [])];
|
|
3261
|
+
console.error(`Live files: OUT OF DATE — the live site does not serve ${paths.length} stored file(s): ${paths.slice(0, 5).join(", ")}${paths.length > 5 ? ", …" : ""}`);
|
|
3262
|
+
console.error(" Refresh them with: blocofy theme push --live --yes");
|
|
3263
|
+
} else if (ls?.pinned === true) {
|
|
3264
|
+
console.log("Live files: up to date");
|
|
3265
|
+
} else if (ls?.pinned === null) {
|
|
3266
|
+
console.error("Live files: could not be checked right now");
|
|
3267
|
+
}
|
|
2691
3268
|
// CF-T9: no imperative one-line fix — which theme holds the pages, why, and safe (preview-first) next steps.
|
|
2692
3269
|
for (const line of healthAdvice(s)) console.error(line);
|
|
2693
3270
|
if (Array.isArray(s.drafts) && s.drafts.length > 0) {
|
|
@@ -2868,6 +3445,7 @@ function commandKey(a, b) {
|
|
|
2868
3445
|
// command → handler. Every failure exits through failAndExit (exit codes 1/2/3, --json envelope last).
|
|
2869
3446
|
const COMMANDS = {
|
|
2870
3447
|
login: login,
|
|
3448
|
+
init: initCommand,
|
|
2871
3449
|
contexts: contextsCommand,
|
|
2872
3450
|
use: useCommand,
|
|
2873
3451
|
logout: logoutCommand,
|