@backifydev/cli 0.2.2 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +12 -1
  2. package/dist/index.js +338 -54
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # @backifydev/cli
2
2
 
3
- Set up and check a Backify application repository.
3
+ Set up, connect, record requests, and ship a Backify application repository. CLI 0.3.0 adds the connected AI delivery workflow.
4
4
 
5
5
  ```sh
6
6
  npx @backifydev/cli init --app app_xxxxx
7
7
  npx @backifydev/cli doctor
8
8
  npx @backifydev/cli sync
9
9
  npx @backifydev/cli login
10
+ npx @backifydev/cli status
11
+ npx @backifydev/cli report --check
12
+ npx @backifydev/cli ship
10
13
  ```
11
14
 
12
15
  `login` opens the Backify portal in your browser. Sign in and select an organization; the portal returns the organization and access token to the CLI through a random, short-lived callback bound to `127.0.0.1`. The CLI stores them in `~/.backify.json`. Set `portalUrl` in that file to use another portal, or `BACKIFY_PORTAL_URL` in the environment. `login <token> [organizationId]` remains available for scripted development.
@@ -18,3 +21,11 @@ The app configuration stores `instructionVersion`, matching the version declared
18
21
  You can start from an empty folder without a Git repository. `init` creates a minimal private `package.json` before installing the SDK, without choosing a framework. Add the application's framework and development/build scripts afterward. Existing manifests are preserved; in a non-empty application or monorepo, run `init` from the appropriate JavaScript/TypeScript package directory.
19
22
 
20
23
  See the [agent setup guide](https://agent.backify.dev/).
24
+
25
+ Copy the application AI prompt in Backify to receive an application-scoped, single-use pairing command. Codes expire after 15 minutes; redeemed sessions expire after seven days. Add `--clone` to the connection command only in an empty directory. Credentials are stored in the user's home configuration, not the project. The default API is `https://api.backify.dev/api`; local development can override it with `BACKIFY_API_URL` or the copied command's `--api` option. HTTPS is required outside loopback.
26
+
27
+ `init` adds a persistent delivery guide linked from the existing agent instructions. Before each application-building request, the agent sends only the user's own text through stdin to `backify prompt record --stdin --id <UUID>`, optionally adding repeated `--filename <basename>` arguments. Reuse the UUID on retries. The command never reads attachments; their bodies, extracted text, full paths, setup/pairing prompts, and credentials must not be submitted. History is displayed oldest-first under the application. This is an explicit agent workflow, not an automatic interceptor for every coding tool.
28
+
29
+ `ship` runs configured checks, verifies the repository/branch, and pushes already-committed source using a short-lived repository-scoped GitHub App credential. It never stages, commits, force-pushes, or fabricates preview testing. It waits for the exact healthy preview, then publishes when policy and permissions allow. Required testing/approval returns a pending state and preview URL, not a false successful production deployment. Use `--preview-only` to stop at preview, or `--timeout <seconds>` to change the default 1200-second wait. Applications must currently be at the repository root; Backify Cloud needs static index.html output, and customer Azure needs a Dockerfile serving port 8080.
30
+
31
+ `disconnect` revokes that user's application CLI sessions. `logout` removes local credentials only. Prompt histories and local reports remain subject to application permissions. Deploy the matching API and publish CLI 0.3.0 before enabling this flow in production.
package/dist/index.js CHANGED
@@ -1,16 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
4
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, rmSync } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { join as join2 } from "node:path";
7
- import { randomBytes } from "node:crypto";
8
- import { createServer } from "node:http";
9
- import { spawn } from "node:child_process";
3
+ // src/development.ts
4
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync3, renameSync, rmSync } from "node:fs";
5
+ import { join as join3, resolve as resolve2 } from "node:path";
6
+ import { spawnSync as spawnSync2 } from "node:child_process";
7
+ import { randomUUID } from "node:crypto";
10
8
 
11
9
  // src/setup.ts
12
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
13
- import { basename, dirname, join, resolve } from "node:path";
10
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync, readdirSync } from "node:fs";
11
+ import { basename, dirname, join as join2, resolve } from "node:path";
14
12
  import { spawnSync } from "node:child_process";
15
13
 
16
14
  // ../platform/dist/index.js
@@ -122,38 +120,54 @@ function validateConfig(value) {
122
120
  }
123
121
  var BACKIFY_SCHEMA = { $schema: "https://json-schema.org/draft/2020-12/schema", $id: `${DOCS_URL}/schema/backify.json`, type: "object", additionalProperties: false, required: ["$schema", "platform", "sdk", "appId", "instructionVersion", "docs", "instructions"], properties: { $schema: { const: `${DOCS_URL}/schema/backify.json` }, platform: { const: "backify" }, sdk: { const: SDK_PACKAGE }, appId: { type: "string", pattern: "^app_[A-Za-z0-9_-]+$" }, instructionVersion: { type: "integer", minimum: 1 }, docs: { type: "string", format: "uri" }, instructions: { type: "object", additionalProperties: false, required: ["preferBackifyCapabilities", "allowDirectCloudInfrastructure"], properties: { preferBackifyCapabilities: { const: true }, allowDirectCloudInfrastructure: { const: false } } } } };
124
122
 
123
+ // src/delivery.ts
124
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
125
+ import { join } from "node:path";
126
+ var deliveryInstructions = "# Backify delivery and prompt history\n\nFor EVERY subsequent user request to build or modify this application, record only the user's own request text before changing code through standard input to:\n\n npx @backifydev/cli@0.3.0 prompt record --stdin --id <new-UUID> [--filename <basename> ...]\n\nRecord requests separately and in order. Reuse the UUID on retries. Never include setup instructions, pairing commands, assistant responses, tool output, attachment contents, extracted text, images, full paths, or credentials. Filename arguments are metadata only; never read attachment bodies to submit them. Retry and report recording failures instead of silently dropping prompts.\n\nUse npx @backifydev/cli@0.3.0 status to inspect hosting and repository readiness. Run npx @backifydev/cli@0.3.0 report --check, fix failures, and commit only application changes relevant to this request. Then run npx @backifydev/cli@0.3.0 ship. The CLI verifies the connected repository and branch, runs checks, pushes committed source with short-lived repository-scoped credentials, waits for its healthy preview, and requests policy-governed publication. It never stages or commits files, force-pushes, changes hosting, or fabricates preview testing.\n\nWhen testing or approval is required, return the preview URL and remaining action. Return a production URL only after healthy activation is verified. Respect the coding tool's permissions and preserve unrelated work. Use --preview-only only when the user requests a preview. Report unsupported hosted runtime requirements instead of substituting another platform.\n\nCredentials belong outside the project. Never store pairing codes or session tokens here or in Git. Expired access requires a new setup prompt or browser login. These instructions are an explicit agent workflow, not a universal chat interceptor.\n";
127
+ function installDeliveryInstructions(root, instructionFiles2) {
128
+ const path = join(root, "BACKIFY-WORKFLOW.md");
129
+ if (existsSync(path) && readFileSync(path, "utf8") !== deliveryInstructions) throw new Error("BACKIFY-WORKFLOW.md contains different instructions. Review it before initializing; it will not be overwritten.");
130
+ writeFileSync(path, deliveryInstructions);
131
+ for (const relative of instructionFiles2) {
132
+ const instructionPath = join(root, relative), content = readFileSync(instructionPath, "utf8");
133
+ if (content.includes("## Backify delivery")) continue;
134
+ const link = relative.startsWith(".github") ? "../BACKIFY-WORKFLOW.md" : "BACKIFY-WORKFLOW.md";
135
+ writeFileSync(instructionPath, content.trimEnd() + "\n\n## Backify delivery\n\nFor application delivery and recording each user request, follow [BACKIFY-WORKFLOW.md](" + link + ").\n");
136
+ }
137
+ }
138
+
125
139
  // src/setup.ts
126
- var instructionFiles = ["AGENTS.md", "CLAUDE.md", join(".github", "copilot-instructions.md")];
140
+ var instructionFiles = ["AGENTS.md", "CLAUDE.md", join2(".github", "copilot-instructions.md")];
127
141
  function detectPackageManager(root = process.cwd()) {
128
- for (const [file, name] of [["pnpm-lock.yaml", "pnpm"], ["yarn.lock", "yarn"], ["bun.lockb", "bun"], ["bun.lock", "bun"], ["package-lock.json", "npm"]]) if (existsSync(join(root, file))) return name;
142
+ for (const [file, name] of [["pnpm-lock.yaml", "pnpm"], ["yarn.lock", "yarn"], ["bun.lockb", "bun"], ["bun.lock", "bun"], ["package-lock.json", "npm"]]) if (existsSync2(join2(root, file))) return name;
129
143
  return "npm";
130
144
  }
131
145
  function updateInstructions(root = process.cwd()) {
132
146
  const updates = instructionFiles.map((relative) => {
133
- const path = join(root, relative);
134
- return { path, content: updateManagedInstructions(existsSync(path) ? readFileSync(path, "utf8") : "") };
147
+ const path = join2(root, relative);
148
+ return { path, content: updateManagedInstructions(existsSync2(path) ? readFileSync2(path, "utf8") : "") };
135
149
  });
136
150
  for (const { path, content } of updates) {
137
151
  mkdirSync(dirname(path), { recursive: true });
138
- writeFileSync(path, content);
152
+ writeFileSync2(path, content);
139
153
  }
140
154
  }
141
155
  function synchronize(root = process.cwd()) {
142
- const path = join(root, "backify.json");
143
- if (!existsSync(path)) throw new Error("backify.json is missing; run backify init --app app_xxxxx");
144
- const value = JSON.parse(readFileSync(path, "utf8")), issues = validateConfig(value);
156
+ const path = join2(root, "backify.json");
157
+ if (!existsSync2(path)) throw new Error("backify.json is missing; run backify init --app app_xxxxx");
158
+ const value = JSON.parse(readFileSync2(path, "utf8")), issues = validateConfig(value);
145
159
  if (issues.length) throw new Error(issues.join("; "));
146
160
  if (value.instructionVersion > INSTRUCTION_VERSION) throw new Error("This app uses newer instructions. Install a matching or newer CLI before syncing.");
147
161
  updateInstructions(root);
148
162
  value.instructionVersion = INSTRUCTION_VERSION;
149
- writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
163
+ writeFileSync2(path, JSON.stringify(value, null, 2) + "\n");
150
164
  }
151
165
  function writeConfig(appId, root = process.cwd()) {
152
- writeFileSync(join(root, "backify.json"), JSON.stringify(createConfig(appId), null, 2) + "\n");
166
+ writeFileSync2(join2(root, "backify.json"), JSON.stringify(createConfig(appId), null, 2) + "\n");
153
167
  }
154
168
  function sdkInstalled(root = process.cwd()) {
155
169
  try {
156
- const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
170
+ const pkg = JSON.parse(readFileSync2(join2(root, "package.json"), "utf8"));
157
171
  return Boolean(pkg.dependencies?.[SDK_PACKAGE] || pkg.devDependencies?.[SDK_PACKAGE]);
158
172
  } catch {
159
173
  return false;
@@ -165,12 +179,12 @@ function installSdk(root = process.cwd()) {
165
179
  if (result.status !== 0) throw new Error(`Could not install ${SDK_PACKAGE} with ${manager}`);
166
180
  }
167
181
  function ensurePackageManifest(root) {
168
- const path = join(root, "package.json");
169
- if (existsSync(path)) return;
182
+ const path = join2(root, "package.json");
183
+ if (existsSync2(path)) return;
170
184
  const setupFiles = /* @__PURE__ */ new Set([".git", ".gitignore", ".github", "AGENTS.md", "CLAUDE.md", "README.md", "LICENSE", "backify.json"]);
171
185
  if (readdirSync(root).some((file) => !setupFiles.has(file))) throw new Error("package.json is missing. Run backify init from the existing application's JavaScript/TypeScript package directory, or start in an empty folder. See https://agent.backify.dev/");
172
186
  const name = basename(resolve(root)).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "backify-app";
173
- writeFileSync(path, JSON.stringify({ name, version: "0.0.0", private: true, type: "module" }, null, 2) + "\n", { flag: "wx" });
187
+ writeFileSync2(path, JSON.stringify({ name, version: "0.0.0", private: true, type: "module" }, null, 2) + "\n", { flag: "wx" });
174
188
  }
175
189
  function initialize(appId, root = process.cwd(), install = true) {
176
190
  if (!/^app_[A-Za-z0-9_-]+$/.test(appId)) throw new Error("Provide an application ID using --app app_xxxxx");
@@ -178,43 +192,247 @@ function initialize(appId, root = process.cwd(), install = true) {
178
192
  if (install) installSdk(root);
179
193
  writeConfig(appId, root);
180
194
  updateInstructions(root);
195
+ installDeliveryInstructions(root, instructionFiles);
181
196
  }
182
197
  function doctor(root = process.cwd()) {
183
198
  const issues = [], warnings = [];
184
199
  if (!sdkInstalled(root)) issues.push(`${SDK_PACKAGE} is not installed`);
185
200
  let config;
186
201
  try {
187
- config = JSON.parse(readFileSync(join(root, "backify.json"), "utf8"));
202
+ config = JSON.parse(readFileSync2(join2(root, "backify.json"), "utf8"));
188
203
  issues.push(...validateConfig(config));
189
204
  } catch {
190
205
  issues.push("backify.json is missing or invalid JSON");
191
206
  }
192
207
  if (config && config.instructionVersion !== INSTRUCTION_VERSION) issues.push(`Repository instruction version ${config.instructionVersion}; CLI version ${INSTRUCTION_VERSION}. Use the matching CLI or run backify sync to upgrade.`);
193
208
  for (const relative of instructionFiles) {
194
- const path = join(root, relative);
195
- if (!existsSync(path)) {
209
+ const path = join2(root, relative);
210
+ if (!existsSync2(path)) {
196
211
  issues.push(`${relative} is missing`);
197
212
  continue;
198
213
  }
199
- issues.push(...managedInstructionIssues(readFileSync(path, "utf8")).map((issue) => `${relative} ${issue}`));
214
+ issues.push(...managedInstructionIssues(readFileSync2(path, "utf8")).map((issue) => `${relative} ${issue}`));
200
215
  }
201
216
  let version = "unknown";
202
217
  try {
203
- const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
218
+ const pkg = JSON.parse(readFileSync2(join2(root, "package.json"), "utf8"));
204
219
  version = pkg.dependencies?.[SDK_PACKAGE] ?? pkg.devDependencies?.[SDK_PACKAGE] ?? "missing";
205
220
  } catch {
206
221
  }
207
222
  return { valid: issues.length === 0, appId: config?.appId, sdkVersion: version, instructionVersion: config?.instructionVersion, currentInstructionVersion: INSTRUCTION_VERSION, issues, warnings };
208
223
  }
209
224
 
225
+ // src/development.ts
226
+ function applicationGuid(appId) {
227
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(appId)) return appId.toLowerCase();
228
+ const match = /^app_([0-9a-f]{32})$/i.exec(appId);
229
+ if (!match) throw new Error("Use the application ID supplied by Backify.");
230
+ const value = match[1];
231
+ return [value.slice(0, 8), value.slice(8, 12), value.slice(12, 16), value.slice(16, 20), value.slice(20)].join("-");
232
+ }
233
+ function projectApplication(root = process.cwd()) {
234
+ if (!existsSync3(join3(root, "backify.json"))) throw new Error("Run backify connect --app <application-id>, then backify init.");
235
+ return JSON.parse(readFileSync3(join3(root, "backify.json"), "utf8")).appId;
236
+ }
237
+ function apiAddress(value) {
238
+ const address = new URL(value);
239
+ if (address.username || address.password || address.search || address.hash || address.protocol !== "https:" && !(address.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(address.hostname))) throw new Error("Backify API must use HTTPS (HTTP is allowed only on loopback for local development).");
240
+ return address.toString().replace(/\/$/, "");
241
+ }
242
+ function apiClient(session, transport = fetch) {
243
+ const base = apiAddress(session.apiUrl);
244
+ return async (path, init) => {
245
+ if (!path.startsWith("/") || path.startsWith("//")) throw new Error("Invalid API path.");
246
+ if (session.expiresAt && Date.parse(session.expiresAt) <= Date.now()) throw new Error("CLI session expired. Copy a new setup prompt or run backify login.");
247
+ const response = await transport(base + path, { ...init, redirect: "error", signal: AbortSignal.timeout(3e4), headers: { "content-type": "application/json", "X-Organization-ID": session.organizationId, ...session.token ? { authorization: "Bearer " + session.token } : {}, ...init?.headers } });
248
+ if (!response.ok) {
249
+ let message = "Backify request failed (" + response.status + ").";
250
+ try {
251
+ const problem = await response.json();
252
+ message = [problem.code, problem.title].filter(Boolean).join(": ") || message;
253
+ } catch {
254
+ }
255
+ throw new Error(message);
256
+ }
257
+ return response.status === 204 ? void 0 : await response.json();
258
+ };
259
+ }
260
+ function saveUserConfig(path, config) {
261
+ const temporary = path + "." + randomUUID() + ".tmp";
262
+ try {
263
+ writeFileSync3(temporary, JSON.stringify(config, null, 2) + "\n", { mode: 384, flag: "wx" });
264
+ if (process.platform === "win32") {
265
+ const account = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
266
+ if (!account) throw new Error("Cannot identify the account to protect CLI credentials.");
267
+ const secured = spawnSync2("icacls", [temporary, "/inheritance:r", "/grant:r", account + ":(F)"], { windowsHide: true, encoding: "utf8" });
268
+ if (secured.status !== 0) throw new Error("Could not restrict CLI credential file permissions.");
269
+ }
270
+ renameSync(temporary, path);
271
+ } finally {
272
+ rmSync(temporary, { force: true });
273
+ }
274
+ }
275
+ function repositoryName(remote) {
276
+ const match = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i.exec(remote);
277
+ if (!match) throw new Error("Repository must be a credential-free GitHub URL.");
278
+ return match[1].toLowerCase();
279
+ }
280
+ function git(args2, root = process.cwd(), environment = process.env) {
281
+ const result = spawnSync2("git", args2, { cwd: root, env: environment, encoding: "utf8", windowsHide: true, maxBuffer: 2 * 1024 * 1024 });
282
+ if (result.status !== 0) throw new Error("Git " + args2[0] + " failed. Check repository access, branch protection, and local changes.");
283
+ return result.stdout.trim();
284
+ }
285
+ function gitEnvironment(repository, token) {
286
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error("Invalid repository.");
287
+ return { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_COUNT: "3", GIT_CONFIG_KEY_0: "http.https://github.com/" + repository + ".git.extraheader", GIT_CONFIG_VALUE_0: "Authorization: Basic " + Buffer.from("x-access-token:" + token).toString("base64"), GIT_CONFIG_KEY_1: "credential.helper", GIT_CONFIG_VALUE_1: "", GIT_CONFIG_KEY_2: "http.followRedirects", GIT_CONFIG_VALUE_2: "false" };
288
+ }
289
+ async function cloneProject(call2, appId, root = process.cwd()) {
290
+ const status = await call2("/apps/" + applicationGuid(appId) + "/developer");
291
+ if (!status.repository || !status.branch || !status.workflowReady) throw new Error("Connect the repository in Backify and wait for workflow setup.");
292
+ if (readdirSync2(root).length) throw new Error("--clone requires an empty directory. Existing files are never overwritten.");
293
+ const credential = await call2("/apps/" + applicationGuid(appId) + "/developer/git-credential", { method: "POST" });
294
+ if (credential.repository.toLowerCase() !== status.repository.toLowerCase()) throw new Error("Repository changed. Run connect again.");
295
+ git(["clone", "--single-branch", "--branch", status.branch, "--", "https://github.com/" + status.repository + ".git", "."], root, gitEnvironment(status.repository, credential.token));
296
+ }
297
+ async function readPrompt(input) {
298
+ const chunks = [];
299
+ let size = 0;
300
+ for await (const chunk of input) {
301
+ const bytes = Buffer.from(chunk);
302
+ size += bytes.length;
303
+ if (size > 128e3) throw new Error("Prompt exceeds the size limit.");
304
+ chunks.push(bytes);
305
+ }
306
+ const text = Buffer.concat(chunks).toString("utf8");
307
+ if (!text.trim() || text.length > 32e3) throw new Error("Provide between 1 and 32000 characters of user prompt text.");
308
+ if (/\bbfcli_[0-9a-f]{64}\b|--pair\s+[0-9a-f]{64}/i.test(text)) throw new Error("Do not record setup prompts or Backify credentials.");
309
+ return text;
310
+ }
311
+ function attachmentNames(values) {
312
+ if (values.length > 50) throw new Error("At most 50 attachment filenames are allowed.");
313
+ return [...new Set(values.map((value) => {
314
+ const filename = value.replace(/\\/g, "/").split("/").pop();
315
+ if (!filename || filename === "." || filename === ".." || filename.length > 255 || /[\x00-\x1f\x7f]/.test(filename)) throw new Error("Invalid attachment filename.");
316
+ return filename;
317
+ }))];
318
+ }
319
+ function localReport(runChecks, root = process.cwd()) {
320
+ const pkg = JSON.parse(readFileSync3(join3(root, "package.json"), "utf8"));
321
+ const dependencies = { ...pkg.devDependencies, ...pkg.dependencies };
322
+ const framework = ["next", "vite", "astro", "@sveltejs/kit", "nuxt", "react", "express"].find((name) => dependencies[name]) ?? "unknown";
323
+ const setup = doctor(root);
324
+ const checks = [{ name: "backify doctor", status: setup.valid ? "passed" : "failed" }];
325
+ const manager = detectPackageManager(root);
326
+ for (const name of ["typecheck", "lint", "test", "build"]) {
327
+ if (!pkg.scripts?.[name]) continue;
328
+ const result = runChecks ? spawnSync2(manager, ["run", name], { cwd: root, stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, CI: "true" }, shell: process.platform === "win32", windowsHide: true, timeout: 10 * 6e4 }) : void 0;
329
+ checks.push({ name, status: result ? result.status === 0 ? "passed" : "failed" : "not-run" });
330
+ }
331
+ let branch = null, commitSha = null, dirty = true;
332
+ try {
333
+ branch = git(["branch", "--show-current"], root);
334
+ commitSha = git(["rev-parse", "HEAD"], root);
335
+ dirty = Boolean(git(["status", "--porcelain"], root));
336
+ } catch {
337
+ }
338
+ const outputDirectory = ["dist", "build", "out", "."].find((directory) => existsSync3(join3(root, directory, "index.html"))) ?? null;
339
+ return { framework, sdkVersion: dependencies["@backifydev/sdk"] ?? null, branch, commitSha, dirty, buildCommand: pkg.scripts?.build ? manager + " run build" : null, outputDirectory, hasDockerfile: existsSync3(join3(root, "Dockerfile")), checks };
340
+ }
341
+ function matchesSource(release, commit) {
342
+ return release?.commitSha === commit || release?.sourceCommitSha === commit;
343
+ }
344
+ async function waitForPreview(call2, prefix, commit, options = {}) {
345
+ const deadline = Date.now() + (options.timeoutMs ?? 20 * 6e4);
346
+ const sleep = options.sleep ?? ((delay) => new Promise((resolve3) => setTimeout(resolve3, delay)));
347
+ do {
348
+ const deployments = await call2(prefix + "/deployments");
349
+ const matching = deployments.find((release) => release.kind === "preview" && matchesSource(release, commit) && !options.previousDeploymentIds?.includes(release.id));
350
+ if (matching?.status === "failed") throw new Error("Deployment of this source failed. Run backify status and inspect its deployment steps.");
351
+ const overview = await call2(prefix + "/releases");
352
+ if (matchesSource(overview.preview, commit) && overview.preview?.status === "succeeded" && overview.preview.healthResult === "healthy") return overview;
353
+ const build = await call2(prefix + "/developer/build?commitSha=" + commit);
354
+ if ((options.previousBuildRunId === void 0 || build.runId !== options.previousBuildRunId) && ["failure", "cancelled", "timed_out", "action_required", "startup_failure"].includes(build.conclusion ?? "")) throw new Error("GitHub build " + build.conclusion + ". " + (build.url ?? "Run backify status for details."));
355
+ await sleep(options.intervalMs ?? 4e3);
356
+ } while (Date.now() < deadline);
357
+ throw new Error("Timed out waiting for this commit's healthy preview. Run backify status; no production release was requested.");
358
+ }
359
+ async function ship(call2, appId, options = {}) {
360
+ const root = options.root ?? process.cwd(), prefix = "/apps/" + applicationGuid(appId);
361
+ const status = await call2(prefix + "/developer");
362
+ if (!status.repository || !status.branch || !status.workflowReady) throw new Error("The application's repository and build workflow must be connected in Backify.");
363
+ if (!doctor(root).valid) throw new Error("Run backify doctor and fix project setup before shipping.");
364
+ if (applicationGuid(projectApplication(root)) !== applicationGuid(appId)) throw new Error("Local application ID does not match the application being shipped.");
365
+ const actualRoot = git(["rev-parse", "--show-toplevel"], root);
366
+ if (resolve2(actualRoot).toLowerCase() !== resolve2(root).toLowerCase()) throw new Error("Shipping currently requires the application at the repository root.");
367
+ const origin = git(["remote", "get-url", "origin"], root);
368
+ const pushOrigin = git(["remote", "get-url", "--push", "origin"], root);
369
+ if (repositoryName(origin) !== status.repository.toLowerCase() || repositoryName(pushOrigin) !== status.repository.toLowerCase()) throw new Error("Local repository does not match this Backify application.");
370
+ if (git(["branch", "--show-current"], root) !== status.branch) throw new Error("Use the connected branch: " + status.branch);
371
+ if (git(["status", "--porcelain"], root)) throw new Error("Commit application changes before shipping. The CLI never stages or commits unrelated files.");
372
+ const commit = git(["rev-parse", "HEAD"], root);
373
+ const report = localReport(true, root);
374
+ await call2(prefix + "/developer/report", { method: "POST", body: JSON.stringify(report) });
375
+ if (report.checks.some((check) => check.status === "failed")) throw new Error("Checks failed; nothing was pushed.");
376
+ if (status.hostingTargetType === "backify-cloud" && !report.outputDirectory) throw new Error("Backify Cloud requires a static build containing index.html.");
377
+ if (status.hostingTargetType === "azure" && !report.hasDockerfile) throw new Error("Customer Azure requires a repository-root Dockerfile.");
378
+ if (git(["status", "--porcelain"], root) || git(["rev-parse", "HEAD"], root) !== commit) throw new Error("Checks changed tracked source. Review and commit those changes before shipping.");
379
+ const previousDeployments = await call2(prefix + "/deployments");
380
+ const previousBuild = await call2(prefix + "/developer/build?commitSha=" + commit);
381
+ const credential = await call2(prefix + "/developer/git-credential", { method: "POST" });
382
+ if (credential.repository.toLowerCase() !== status.repository.toLowerCase()) throw new Error("Repository changed; reconnect before shipping.");
383
+ const environment = gitEnvironment(status.repository, credential.token);
384
+ const destination = "https://github.com/" + status.repository + ".git";
385
+ const remoteHead = git(["ls-remote", "--", destination, "refs/heads/" + status.branch], root, environment).split(/\s+/)[0];
386
+ git(["push", "--", destination, commit + ":refs/heads/" + status.branch], root, environment);
387
+ if (remoteHead === commit && !(matchesSource(status.releases.preview, commit) && status.releases.preview?.status === "succeeded")) await call2(prefix + "/deployments/preview", { method: "POST", body: JSON.stringify({ sourceRevision: "latest" }) });
388
+ const overview = await waitForPreview(call2, prefix, commit, { timeoutMs: options.timeoutMs, previousDeploymentIds: previousDeployments.map((deployment) => deployment.id), previousBuildRunId: previousBuild.runId ?? null });
389
+ if (overview.preview.commitSha !== commit) {
390
+ if (git(["status", "--porcelain"], root) || git(["rev-parse", "HEAD"], root) !== commit) throw new Error("Local work changed while the preview built. Review it before synchronizing the build commit.");
391
+ const refreshed = await call2(prefix + "/developer/git-credential", { method: "POST" });
392
+ if (refreshed.repository.toLowerCase() !== status.repository.toLowerCase()) throw new Error("Repository changed while building.");
393
+ git(["fetch", "--", destination, "refs/heads/" + status.branch], root, gitEnvironment(status.repository, refreshed.token));
394
+ git(["merge", "--ff-only", overview.preview.commitSha], root);
395
+ }
396
+ return publishPreview(call2, prefix, overview, options);
397
+ }
398
+ async function publishPreview(call2, prefix, overview, options = {}) {
399
+ if (!overview.preview || overview.preview.status !== "succeeded" || overview.preview.healthResult !== "healthy") throw new Error("Only a verified healthy preview can be published.");
400
+ const preview = overview.preview;
401
+ if (options.previewOnly) return { status: "preview-ready", url: preview.url };
402
+ if (overview.policy.requirePreviewTesting && !preview.testedAt) return { status: "testing-required", url: preview.url };
403
+ if (overview.live?.previewDeploymentId === preview.id && overview.live.status === "succeeded") return { status: "live", url: overview.live.url };
404
+ const promotion = await call2(prefix + "/deployments/promote", { method: "POST", headers: { "Idempotency-Key": "cli-ship-" + preview.id }, body: JSON.stringify({ previewDeploymentId: preview.id, tested: false }) });
405
+ if (promotion.status === "awaiting-approval" || promotion.approvalId) return { status: "awaiting-approval", url: preview.url };
406
+ if (!promotion.operationId) throw new Error("Publication returned no operation to verify. Run backify status.");
407
+ const deadline = Date.now() + (options.timeoutMs ?? 20 * 6e4);
408
+ do {
409
+ const operation = await call2("/operations/" + promotion.operationId);
410
+ if (operation.status === "failed" || operation.status === "cancelled") throw new Error(operation.errorMessage ?? "Production deployment did not complete.");
411
+ if (operation.status === "succeeded") {
412
+ const releases = await call2(prefix + "/releases");
413
+ if (releases.live?.previewDeploymentId !== preview.id || releases.live.status !== "succeeded" || releases.live.healthResult !== "healthy") throw new Error("Production did not activate this healthy release. Run backify status.");
414
+ return { status: "live", url: releases.live.url };
415
+ }
416
+ await new Promise((resolve3) => setTimeout(resolve3, 4e3));
417
+ } while (Date.now() < deadline);
418
+ throw new Error("Publication is still pending. Run backify status; deployment success has not been verified.");
419
+ }
420
+
210
421
  // src/index.ts
211
- var configPath = join2(homedir(), ".backify.json");
212
- var userConfig = existsSync2(configPath) ? JSON.parse(readFileSync2(configPath, "utf8")) : { apiUrl: process.env.BACKIFY_API_URL ?? "http://localhost:5080/api" };
422
+ import { readFileSync as readFileSync4, existsSync as existsSync4, rmSync as rmSync2 } from "node:fs";
423
+ import { homedir } from "node:os";
424
+ import { join as join4 } from "node:path";
425
+ import { randomBytes } from "node:crypto";
426
+ import { createServer } from "node:http";
427
+ import { spawn } from "node:child_process";
428
+ var configPath = join4(homedir(), ".backify.json");
429
+ var userConfig = existsSync4(configPath) ? JSON.parse(readFileSync4(configPath, "utf8")) : {};
213
430
  var [command, ...args] = process.argv.slice(2);
214
431
  async function call(path, init) {
215
- const response = await fetch(userConfig.apiUrl + path, { ...init, headers: { "content-type": "application/json", "X-Organization-ID": userConfig.organizationId ?? "", ...userConfig.token ? { authorization: `Bearer ${userConfig.token}` } : {} } });
216
- if (!response.ok) throw new Error(await response.text());
217
- return response.status === 204 ? {} : response.json();
432
+ let appId = option("--app");
433
+ if (!appId && existsSync4(join4(process.cwd(), "backify.json"))) appId = projectApplication();
434
+ const session = appId ? userConfig.sessions?.["app_" + applicationGuid(appId).replaceAll("-", "")] : void 0;
435
+ return apiClient(session ?? { token: userConfig.token ?? "", organizationId: userConfig.organizationId ?? "", apiUrl: process.env.BACKIFY_API_URL ?? userConfig.apiUrl ?? "https://api.backify.dev/api" })(path, init);
218
436
  }
219
437
  var option = (name) => {
220
438
  const index = args.indexOf(name);
@@ -246,7 +464,7 @@ async function browserLogin() {
246
464
  res.end("Backify CLI connected. You can close this tab.");
247
465
  userConfig.token = value.token;
248
466
  userConfig.organizationId = value.organizationId;
249
- writeFileSync2(configPath, JSON.stringify(userConfig, null, 2));
467
+ saveUserConfig(configPath, userConfig);
250
468
  server.close();
251
469
  resolveLogin();
252
470
  } catch {
@@ -257,7 +475,7 @@ async function browserLogin() {
257
475
  });
258
476
  let resolveLogin = () => {
259
477
  };
260
- const completed = new Promise((resolve2) => resolveLogin = resolve2);
478
+ const completed = new Promise((resolve3) => resolveLogin = resolve3);
261
479
  server.listen(0, "127.0.0.1", () => {
262
480
  const address = server.address();
263
481
  if (!address || typeof address === "string") throw new Error("Could not start local login callback");
@@ -284,20 +502,16 @@ async function browserLogin() {
284
502
  async function main() {
285
503
  switch (command) {
286
504
  case "init":
287
- initialize(option("--app") ?? args.find((x) => !x.startsWith("-")) ?? "");
288
- console.log("Backify initialized. SDK, configuration, and agent instructions are ready.");
505
+ initialize(option("--app") ?? args.find((value) => !value.startsWith("-")) ?? "");
506
+ console.log("Backify initialized. SDK, delivery instructions, and prompt history guidance are ready.");
289
507
  break;
290
- case "sync": {
508
+ case "sync":
291
509
  synchronize();
292
- console.log(`Backify instructions synchronized to version ${INSTRUCTION_VERSION}.`);
510
+ console.log("Backify instructions synchronized to version " + INSTRUCTION_VERSION);
293
511
  break;
294
- }
295
512
  case "doctor": {
296
513
  const result = doctor();
297
- console.log(`Backify doctor: ${result.valid ? "healthy" : "invalid"}`);
298
- console.log(`App: ${result.appId ?? "missing"} \xB7 SDK: ${result.sdkVersion} \xB7 Instructions: ${result.instructionVersion ?? "missing"}/${result.currentInstructionVersion}`);
299
- for (const warning of result.warnings) console.warn(`warning: ${warning}`);
300
- for (const issue of result.issues) console.error(`error: ${issue}`);
514
+ console.log(JSON.stringify(result, null, 2));
301
515
  if (!result.valid) process.exitCode = 1;
302
516
  break;
303
517
  }
@@ -305,28 +519,98 @@ async function main() {
305
519
  if (args[0]) {
306
520
  userConfig.token = args[0];
307
521
  userConfig.organizationId = args[1] ?? userConfig.organizationId;
308
- writeFileSync2(configPath, JSON.stringify(userConfig, null, 2));
309
- console.log("Logged in to Backify");
310
- } else {
522
+ saveUserConfig(configPath, userConfig);
523
+ } else await browserLogin();
524
+ console.log("Logged in to Backify");
525
+ break;
526
+ case "logout":
527
+ rmSync2(configPath, { force: true });
528
+ console.log("Local CLI credentials removed. Use disconnect before logout to revoke an application session.");
529
+ break;
530
+ case "connect": {
531
+ const appId = option("--app") ?? projectApplication(), key = "app_" + applicationGuid(appId).replaceAll("-", "");
532
+ const code = option("--pair"), organizationId = option("--organization") ?? userConfig.organizationId;
533
+ const address = apiAddress(option("--api") ?? process.env.BACKIFY_API_URL ?? userConfig.apiUrl ?? "https://api.backify.dev/api");
534
+ if (code) {
535
+ if (!organizationId) throw new Error("The setup prompt must supply the organization.");
536
+ const redeem = apiClient({ token: "", organizationId, apiUrl: address });
537
+ const paired = await redeem("/apps/" + applicationGuid(appId) + "/developer/pair", { method: "POST", body: JSON.stringify({ code }) });
538
+ userConfig.sessions ??= {};
539
+ userConfig.sessions[key] = { ...paired, apiUrl: address };
540
+ saveUserConfig(configPath, userConfig);
541
+ } else if (!userConfig.sessions?.[key] && !userConfig.token) {
311
542
  await browserLogin();
312
- console.log("Logged in to Backify");
313
543
  }
314
- ;
544
+ const session = userConfig.sessions?.[key] ?? { token: userConfig.token ?? "", organizationId: organizationId ?? userConfig.organizationId ?? "", apiUrl: address };
545
+ const client = apiClient(session);
546
+ await client("/apps/" + applicationGuid(appId) + "/developer");
547
+ userConfig.sessions ??= {};
548
+ userConfig.sessions[key] = session;
549
+ saveUserConfig(configPath, userConfig);
550
+ if (args.includes("--clone")) await cloneProject(client, appId);
551
+ console.log(JSON.stringify(await client("/apps/" + applicationGuid(appId) + "/developer"), null, 2));
315
552
  break;
316
- case "logout":
317
- rmSync(configPath, { force: true });
553
+ }
554
+ case "disconnect":
555
+ await call("/apps/" + applicationGuid(projectApplication()) + "/developer/session", { method: "DELETE" });
556
+ delete userConfig.sessions?.["app_" + applicationGuid(projectApplication()).replaceAll("-", "")];
557
+ saveUserConfig(configPath, userConfig);
558
+ console.log("Application CLI sessions revoked.");
559
+ break;
560
+ case "status": {
561
+ const prefix = "/apps/" + applicationGuid(option("--app") ?? projectApplication());
562
+ console.log(JSON.stringify(await call(prefix + "/developer"), null, 2));
563
+ const deployments = await call(prefix + "/deployments");
564
+ const latest = deployments[0];
565
+ if (latest?.status === "failed") console.log(JSON.stringify(await call(prefix + "/deployments/" + latest.id), null, 2));
566
+ try {
567
+ const commit = git(["rev-parse", "HEAD"]);
568
+ console.log(JSON.stringify(await call(prefix + "/developer/build?commitSha=" + commit), null, 2));
569
+ } catch {
570
+ console.log("Build status is unavailable for the current local commit.");
571
+ }
572
+ break;
573
+ }
574
+ case "report": {
575
+ const report = localReport(args.includes("--check"));
576
+ await call("/apps/" + applicationGuid(projectApplication()) + "/developer/report", { method: "POST", body: JSON.stringify(report) });
577
+ console.log(JSON.stringify(report, null, 2));
578
+ if (report.checks.some((check) => check.status === "failed")) process.exitCode = 1;
579
+ break;
580
+ }
581
+ case "prompt": {
582
+ if (args[0] !== "record" || !args.includes("--stdin")) throw new Error("Use backify prompt record --stdin --id <UUID> [--filename <name> ...]. Only user text and filenames are sent.");
583
+ const id = option("--id");
584
+ if (!id || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) throw new Error("Provide a UUID with --id; reuse it when retrying this prompt.");
585
+ const filenames = attachmentNames(args.flatMap((value, index) => value === "--filename" ? [args[index + 1] ?? ""] : []));
586
+ const text = await readPrompt(process.stdin);
587
+ console.log(JSON.stringify(await call("/apps/" + applicationGuid(projectApplication()) + "/prompts", { method: "POST", body: JSON.stringify({ clientPromptId: id, text, filenames }) })));
588
+ break;
589
+ }
590
+ case "ship": {
591
+ const timeout = Number(option("--timeout") ?? "1200");
592
+ if (!Number.isFinite(timeout) || timeout < 1 || timeout > 7200) throw new Error("--timeout must be between 1 and 7200 seconds.");
593
+ console.log(JSON.stringify(await ship(call, projectApplication(), { previewOnly: args.includes("--preview-only"), timeoutMs: timeout * 1e3 }), null, 2));
594
+ break;
595
+ }
596
+ case "request": {
597
+ const workspaceId = option("--workspace"), name = option("--name"), slug = option("--slug"), description = option("--description"), configurationFile = option("--configuration");
598
+ if (!workspaceId || !name || !slug || !description) throw new Error("Use backify request --workspace <id> --name <name> --slug <slug> --description <purpose> [--configuration <json-file>]");
599
+ const configuration = configurationFile ? JSON.parse(readFileSync4(configurationFile, "utf8")) : void 0;
600
+ console.log(await call("/apps/requests", { method: "POST", body: JSON.stringify({ workspaceId, name, slug, description, configuration }) }));
318
601
  break;
602
+ }
319
603
  case "apps":
320
604
  console.table(await call("/apps"));
321
605
  break;
322
606
  case "deploy":
323
- console.log(await call(`/apps/${args[0]}/deployments/preview`, { method: "POST", body: JSON.stringify({ sourceRevision: args[1] ?? "local" }) }));
607
+ console.log(await call("/apps/" + applicationGuid(args[0]) + "/deployments/preview", { method: "POST", body: JSON.stringify({ sourceRevision: args[1] ?? "latest" }) }));
324
608
  break;
325
609
  default:
326
- console.log("backify init --app app_xxxxx | sync | doctor | login | logout | apps | deploy");
610
+ console.log("backify connect | init | doctor | sync | login | logout | disconnect | status | report [--check] | prompt record --stdin --id <UUID> | ship [--preview-only] | request | apps | deploy");
327
611
  }
328
612
  }
329
613
  main().catch((error) => {
330
- console.error(error instanceof Error ? error.message : error);
614
+ console.error(error instanceof Error ? error.message : String(error));
331
615
  process.exitCode = 1;
332
616
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backifydev/cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Command-line setup and diagnostics for Backify applications",
5
5
  "type": "module",
6
6
  "bin": { "backify": "dist/index.js" },