@fabricorg/cli 0.3.1 → 0.3.3

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/dist/fabric.js CHANGED
@@ -57,7 +57,7 @@ import { Command as Command20 } from "commander";
57
57
  // package.json
58
58
  var package_default = {
59
59
  name: "@fabricorg/cli",
60
- version: "0.3.1",
60
+ version: "0.3.3",
61
61
  description: "CLI for the Fabric AI platform",
62
62
  keywords: [
63
63
  "fabric",
@@ -1369,6 +1369,7 @@ import { chmod } from "fs/promises";
1369
1369
  // src/lib/instructions/paths.ts
1370
1370
  var DRIVE_LETTER = /^[A-Za-z]:/;
1371
1371
  var RESERVED_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i;
1372
+ var FORBIDDEN_NAME_CHARACTER = /[<>:"|?*]/;
1372
1373
  var RESERVED_ROOTS = [".git", ".fabric"];
1373
1374
  var RESERVED_EXACT_PATHS = [".claude/settings.local.json"];
1374
1375
  function hasControlCharacter(input) {
@@ -1418,10 +1419,10 @@ function checkRelativePath(input) {
1418
1419
  detail: input
1419
1420
  };
1420
1421
  }
1421
- if (segment.includes(":")) {
1422
+ if (FORBIDDEN_NAME_CHARACTER.test(segment)) {
1422
1423
  return {
1423
1424
  ok: false,
1424
- reason: "alternate_data_stream",
1425
+ reason: "forbidden_character",
1425
1426
  detail: input
1426
1427
  };
1427
1428
  }
@@ -1472,8 +1473,8 @@ function describeRejection(rejection) {
1472
1473
  return `path segment ending in a dot or space refused: ${rejection.detail}`;
1473
1474
  case "reserved_device_name":
1474
1475
  return `reserved device name refused: ${rejection.detail}`;
1475
- case "alternate_data_stream":
1476
- return `colon in path refused: ${rejection.detail}`;
1476
+ case "forbidden_character":
1477
+ return `path contains a character Windows will not put in a filename (< > : " | ? *): ${rejection.detail}`;
1477
1478
  case "reserved_path":
1478
1479
  return `this tool never writes or deletes ${RESERVED_ROOTS.join(", ")} or ${RESERVED_EXACT_PATHS.join(", ")}: ${rejection.detail}`;
1479
1480
  case "not_a_regular_file":
@@ -2578,11 +2579,158 @@ function nextLock(input) {
2578
2579
  };
2579
2580
  }
2580
2581
 
2582
+ // src/lib/instructions/push.ts
2583
+ import { createHash as createHash5 } from "crypto";
2584
+ var MAX_PUSH_CHANGES = 50;
2585
+ function assertPushablePath(candidate, source) {
2586
+ const check = checkRelativePath(candidate);
2587
+ if (!check.ok) {
2588
+ throw new Error(
2589
+ `Refusing to push: ${describeRejection(check)}. ${source}`
2590
+ );
2591
+ }
2592
+ if (isReservedPath(candidate)) {
2593
+ throw new Error(
2594
+ `Refusing to push: ${describeRejection({
2595
+ ok: false,
2596
+ reason: "reserved_path",
2597
+ detail: candidate
2598
+ })}. ${source}`
2599
+ );
2600
+ }
2601
+ }
2602
+ function encodeContent(bytes) {
2603
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
2604
+ const roundTrip = new TextEncoder().encode(text);
2605
+ if (roundTrip.length === bytes.length && roundTrip.every((byte, index) => byte === bytes[index])) {
2606
+ return { content: text, encoding: "utf8" };
2607
+ }
2608
+ return {
2609
+ content: Buffer.from(bytes).toString("base64"),
2610
+ encoding: "base64"
2611
+ };
2612
+ }
2613
+ function refuseStaleLedger(detail) {
2614
+ throw new Error(
2615
+ `Refusing to push: the sync lock does not match the published version \u2014 ${detail}. Run \`fabric instructions sync\` and try again. Nothing was read and nothing was sent.`
2616
+ );
2617
+ }
2618
+ function assertLockMatchesManifest(lock, manifest) {
2619
+ const published = new Map(
2620
+ manifest.map((entry) => [entry.path, entry.sha256])
2621
+ );
2622
+ for (const [path5, entry] of Object.entries(lock.files)) {
2623
+ const expected = published.get(path5);
2624
+ if (expected === void 0) {
2625
+ refuseStaleLedger(
2626
+ `it lists ${path5}, which version ${lock.snapshotVersion} does not contain`
2627
+ );
2628
+ }
2629
+ if (expected !== entry.sha256) {
2630
+ refuseStaleLedger(
2631
+ `its record of ${path5} is not the published file`
2632
+ );
2633
+ }
2634
+ }
2635
+ for (const entry of manifest) {
2636
+ if (!Object.hasOwn(lock.files, entry.path)) {
2637
+ refuseStaleLedger(
2638
+ `version ${lock.snapshotVersion} contains ${entry.path}, which the lock does not list`
2639
+ );
2640
+ }
2641
+ }
2642
+ }
2643
+ function computePushPlan(input) {
2644
+ return __async(this, null, function* () {
2645
+ var _a2, _b;
2646
+ const { root, lock } = input;
2647
+ const lockedPaths = Object.keys(lock.files);
2648
+ for (const lockedPath of lockedPaths) {
2649
+ assertPushablePath(
2650
+ lockedPath,
2651
+ "The lock names a path this tool will not touch."
2652
+ );
2653
+ }
2654
+ assertLockMatchesManifest(lock, input.manifest);
2655
+ const lockCollision = findCollision(lockedPaths);
2656
+ if (lockCollision !== null) {
2657
+ throw new Error(
2658
+ `Refusing to push: ${describeRejection({
2659
+ ok: false,
2660
+ reason: "collision",
2661
+ detail: `${lockCollision.first} and ${lockCollision.second}`
2662
+ })}. The lock names two paths this tool cannot tell apart.`
2663
+ );
2664
+ }
2665
+ const added = [];
2666
+ const locked = new Set(lockedPaths);
2667
+ for (const candidate of (_a2 = input.added) != null ? _a2 : []) {
2668
+ assertPushablePath(candidate, "That path cannot be pushed.");
2669
+ if (locked.has(candidate)) {
2670
+ continue;
2671
+ }
2672
+ if (added.includes(candidate)) {
2673
+ continue;
2674
+ }
2675
+ added.push(candidate);
2676
+ }
2677
+ const allCollision = findCollision([...lockedPaths, ...added]);
2678
+ if (allCollision !== null) {
2679
+ throw new Error(
2680
+ `Refusing to push: ${describeRejection({
2681
+ ok: false,
2682
+ reason: "collision",
2683
+ detail: `${allCollision.first} and ${allCollision.second}`
2684
+ })}. Two of these paths are one file on a case-insensitive filesystem.`
2685
+ );
2686
+ }
2687
+ const entries = [];
2688
+ const changes = [];
2689
+ const unchanged = [];
2690
+ for (const lockedPath of lockedPaths.sort()) {
2691
+ const read = yield readFileSafely(root, lockedPath);
2692
+ if (read === null) {
2693
+ entries.push({ path: lockedPath, action: "delete" });
2694
+ changes.push({ op: "delete", path: lockedPath });
2695
+ continue;
2696
+ }
2697
+ const actual = createHash5("sha256").update(read.bytes).digest("hex");
2698
+ if (actual === ((_b = lock.files[lockedPath]) == null ? void 0 : _b.sha256)) {
2699
+ unchanged.push(lockedPath);
2700
+ continue;
2701
+ }
2702
+ const encoded = encodeContent(read.bytes);
2703
+ entries.push({
2704
+ path: lockedPath,
2705
+ action: "put",
2706
+ size: read.bytes.length
2707
+ });
2708
+ changes.push(__spreadValues({ op: "put", path: lockedPath }, encoded));
2709
+ }
2710
+ for (const newPath of added.sort()) {
2711
+ const read = yield readFileSafely(root, newPath);
2712
+ if (read === null) {
2713
+ throw new Error(
2714
+ `Refusing to push: there is no file at ${newPath}.`
2715
+ );
2716
+ }
2717
+ const encoded = encodeContent(read.bytes);
2718
+ entries.push({ path: newPath, action: "put", size: read.bytes.length });
2719
+ changes.push(__spreadValues({ op: "put", path: newPath }, encoded));
2720
+ }
2721
+ return { entries, changes, unchanged };
2722
+ });
2723
+ }
2724
+
2581
2725
  // src/commands/instructions/index.ts
2582
2726
  var HOOK_DEADLINE_MS = 1e4;
2583
2727
  var CHECK_TIMEOUT_MS = 5e3;
2584
2728
  var SYNC_TIMEOUT_MS = 15e3;
2585
2729
  var BUNDLE_TIMEOUT_MS = 6e4;
2730
+ var PUSH_TIMEOUT_MS = 6e4;
2731
+ function collectAdded(value, previous) {
2732
+ return [...previous, value];
2733
+ }
2586
2734
  var CliFailure = class extends Error {
2587
2735
  constructor(message, exitCode) {
2588
2736
  super(message);
@@ -2617,6 +2765,22 @@ function buildInstructionsCommand() {
2617
2765
  yield run(opts, "sync", () => runSync(opts, formatFor(this)));
2618
2766
  });
2619
2767
  });
2768
+ instructions.command("push").description(
2769
+ "Suggest this checkout's edits to the project's coding instructions"
2770
+ ).requiredOption("--project <id>", "Project ID").option("--dest <dir>", "Destination directory (default: cwd)").option("--org <slug>", "Organization context").option(
2771
+ "--add <path>",
2772
+ "Also send a file the last sync did not write (repeatable)",
2773
+ collectAdded,
2774
+ []
2775
+ ).option("--dry-run", "Print the change set and send nothing").option("--format <format>", "Output format: text|json").action(function(opts) {
2776
+ return __async(this, null, function* () {
2777
+ yield run(
2778
+ __spreadProps(__spreadValues({}, opts), { hook: false }),
2779
+ "push",
2780
+ () => runPush(opts, formatFor(this))
2781
+ );
2782
+ });
2783
+ });
2620
2784
  instructions.command("init").description(
2621
2785
  "Write a SessionStart hook for a coding tool and take the first copy"
2622
2786
  ).requiredOption("--project <id>", "Project ID").requiredOption(
@@ -2721,7 +2885,7 @@ function destinationOf(opts) {
2721
2885
  var _a2;
2722
2886
  return path4.resolve((_a2 = opts.dest) != null ? _a2 : process.cwd());
2723
2887
  }
2724
- function instructionsClient(opts, timeoutMs) {
2888
+ function instructionsClient(opts, timeoutMs, { neverRetry = false } = {}) {
2725
2889
  if (!getApiKey()) {
2726
2890
  throw new CliFailure(
2727
2891
  "Not authenticated. Run: fabric auth login --key <api-key>",
@@ -2730,11 +2894,22 @@ function instructionsClient(opts, timeoutMs) {
2730
2894
  }
2731
2895
  return getClient(
2732
2896
  opts.hook ? {
2897
+ // Hook mode has ONE absolute deadline covering every
2898
+ // call it makes, so it cannot spend it on retries: a
2899
+ // session that will not start is worse than instructions
2900
+ // one version stale.
2733
2901
  timeoutMs: Math.min(timeoutMs, HOOK_DEADLINE_MS),
2734
2902
  retry: { maxRetries: 0 }
2735
- } : { timeoutMs }
2903
+ } : neverRetry ? { timeoutMs, retry: { maxRetries: 0 } } : { timeoutMs }
2736
2904
  ).withoutContext();
2737
2905
  }
2906
+ function downloadUrlClient(opts) {
2907
+ return instructionsClient(
2908
+ opts,
2909
+ opts.hook ? HOOK_DEADLINE_MS : BUNDLE_TIMEOUT_MS,
2910
+ { neverRetry: true }
2911
+ );
2912
+ }
2738
2913
  function orgSlugFor(opts) {
2739
2914
  return opts.org;
2740
2915
  }
@@ -2945,10 +3120,9 @@ function syncOnce(opts) {
2945
3120
  const org = orgSlugFor(opts);
2946
3121
  let download;
2947
3122
  try {
2948
- download = yield client.instructions.createDownloadUrl(
2949
- opts.project,
2950
- { org }
2951
- );
3123
+ download = yield downloadUrlClient(
3124
+ opts
3125
+ ).instructions.createDownloadUrl(opts.project, { org });
2952
3126
  } catch (error) {
2953
3127
  throw asCliFailure(error);
2954
3128
  }
@@ -3044,6 +3218,153 @@ function reportSync(outcome, opts) {
3044
3218
  line(`Everything in ${outcome.destination} already matched.`);
3045
3219
  }
3046
3220
  }
3221
+ function runPush(opts, format) {
3222
+ return __async(this, null, function* () {
3223
+ var _a2;
3224
+ const destination = destinationOf(opts);
3225
+ const root = yield resolveExistingRoot(destination);
3226
+ const lock = yield readLockForProject(root, opts.project);
3227
+ if (lock === null) {
3228
+ throw new CliFailure(
3229
+ `No coding-instructions lock in ${destination}. Run \`fabric instructions sync --project ${opts.project}\` first: a push is a diff against the version that was last applied here, and without that ledger there is nothing to diff against.`,
3230
+ 7
3231
+ );
3232
+ }
3233
+ const client = instructionsClient(
3234
+ __spreadProps(__spreadValues({}, opts), { hook: false }),
3235
+ PUSH_TIMEOUT_MS
3236
+ );
3237
+ const published = yield fetchPublished(client, opts);
3238
+ if (!published.published || !published.snapshot) {
3239
+ throw new CliFailure(
3240
+ "this project has no published coding instructions to change yet; the first version is uploaded from the Coding Instructions tab",
3241
+ 4
3242
+ );
3243
+ }
3244
+ if (published.snapshot.id !== lock.snapshotId) {
3245
+ throw new CliFailure(
3246
+ "published instructions moved past your last sync; run `fabric instructions sync` then push again",
3247
+ 7
3248
+ );
3249
+ }
3250
+ const plan = yield computePushPlan({
3251
+ root,
3252
+ lock,
3253
+ manifest: (_a2 = published.manifest) != null ? _a2 : [],
3254
+ added: opts.add
3255
+ });
3256
+ if (plan.changes.length === 0) {
3257
+ throw new CliFailure(
3258
+ `Nothing to push: every file the last sync wrote still matches version ${lock.snapshotVersion}. Add a new file with --add <path> if you meant to suggest one.`,
3259
+ 7
3260
+ );
3261
+ }
3262
+ if (plan.changes.length > MAX_PUSH_CHANGES) {
3263
+ throw new CliFailure(
3264
+ `Too many changes to push (${plan.changes.length} > ${MAX_PUSH_CHANGES}). A change set this large is a replacement rather than an edit \u2014 upload the folder from the project's Coding Instructions tab, which is also the only path that re-reads the project's exclusion rules.`,
3265
+ 7
3266
+ );
3267
+ }
3268
+ const outcome = {
3269
+ projectId: opts.project,
3270
+ destination: root,
3271
+ dryRun: Boolean(opts.dryRun),
3272
+ baseSnapshotId: lock.snapshotId,
3273
+ baseVersion: lock.snapshotVersion,
3274
+ put: plan.entries.filter((entry) => entry.action === "put").map((entry) => entry.path),
3275
+ deleted: plan.entries.filter((entry) => entry.action === "delete").map((entry) => entry.path),
3276
+ unchanged: plan.unchanged.length,
3277
+ snapshotId: null,
3278
+ version: null,
3279
+ proposalStatus: null,
3280
+ status: null
3281
+ };
3282
+ if (!opts.dryRun) {
3283
+ try {
3284
+ const submitted = yield client.instructions.submitChange(
3285
+ opts.project,
3286
+ lock.snapshotId,
3287
+ plan.changes,
3288
+ { org: orgSlugFor(opts) }
3289
+ );
3290
+ outcome.snapshotId = submitted.snapshotId;
3291
+ outcome.version = submitted.version;
3292
+ outcome.proposalStatus = submitted.proposalStatus;
3293
+ outcome.status = submitted.status;
3294
+ } catch (error) {
3295
+ throw asPushFailure(error);
3296
+ }
3297
+ }
3298
+ if (format === "json") {
3299
+ printOutput(outcome, { format: "json" });
3300
+ return;
3301
+ }
3302
+ reportPush(outcome);
3303
+ });
3304
+ }
3305
+ function asPushFailure(error) {
3306
+ const code = error.code;
3307
+ const message = error instanceof Error ? error.message : String(error);
3308
+ switch (code) {
3309
+ case "PULL_FIRST":
3310
+ return new CliFailure(
3311
+ "published instructions moved past your last sync; run `fabric instructions sync` then push again",
3312
+ 7
3313
+ );
3314
+ case "REPOSITORY_SOURCE_OF_TRUTH":
3315
+ return new CliFailure(
3316
+ "this project's coding instructions come from its repository, so they are changed there and mirrored into Fabric \u2014 commit and push to the repository instead; nothing was sent",
3317
+ 7
3318
+ );
3319
+ case "NOTHING_PUBLISHED":
3320
+ return new CliFailure(
3321
+ "this project has no published coding instructions to change yet; the first version is uploaded from the Coding Instructions tab",
3322
+ 4
3323
+ );
3324
+ case "PROPOSAL_PROPOSER_LIMIT":
3325
+ case "PROPOSAL_PROJECT_LIMIT":
3326
+ return new CliFailure(`${message} Nothing was sent.`, 7);
3327
+ default:
3328
+ return asCliFailure(error);
3329
+ }
3330
+ }
3331
+ function pushVerdict(outcome) {
3332
+ var _a2;
3333
+ if (outcome.proposalStatus === "PENDING" && outcome.status === "RECEIVING") {
3334
+ return `An earlier attempt is still sending this change; if version ${outcome.version} stays unfinished, cancel it in the project's Coding Instructions tab, or leave it and it is closed out automatically after six hours.`;
3335
+ }
3336
+ if (outcome.proposalStatus === "PENDING" && outcome.status === "FAILED") {
3337
+ return `This proposal did not pass its checks; retry version ${outcome.version} from the project's Coding Instructions tab.`;
3338
+ }
3339
+ if (outcome.proposalStatus === "PENDING") {
3340
+ if (outcome.status === "REJECTED") {
3341
+ return `Version ${outcome.version} was rejected by its checks \u2014 cancel it in the project's Coding Instructions tab before proposing this change again.`;
3342
+ }
3343
+ return `Proposed as version ${outcome.version}. It is pending review \u2014 nothing is published until somebody who can edit this project's coding instructions approves it in the Coding Instructions tab.`;
3344
+ }
3345
+ if (outcome.proposalStatus === "APPROVED") {
3346
+ return `Version ${outcome.version} has already been approved and published \u2014 run \`fabric instructions sync\` to bring this checkout up to it.`;
3347
+ }
3348
+ return `Version ${outcome.version} is no longer open for review (${(_a2 = outcome.proposalStatus) != null ? _a2 : outcome.status}): an earlier attempt at this same change was closed out, so push again.`;
3349
+ }
3350
+ function reportPush(outcome) {
3351
+ const prefix = outcome.dryRun ? "Would send" : "Sent";
3352
+ line(
3353
+ `${prefix} ${outcome.put.length} changed file(s) and ${outcome.deleted.length} deletion(s) against version ${outcome.baseVersion} (${outcome.unchanged} unchanged).`
3354
+ );
3355
+ listPaths("changed", outcome.put);
3356
+ listPaths("deleted", outcome.deleted);
3357
+ if (outcome.dryRun) {
3358
+ line(
3359
+ "Nothing was sent. Without --dry-run this would open a proposal for review."
3360
+ );
3361
+ return;
3362
+ }
3363
+ line(pushVerdict(outcome));
3364
+ line(
3365
+ "Your lock was not changed: it still names the published version, which is what `fabric instructions sync` compares against."
3366
+ );
3367
+ }
3047
3368
  function runInit(opts, format) {
3048
3369
  return __async(this, null, function* () {
3049
3370
  if (opts.tool !== "claude-code") {