@asteroidai/cli 1.9.2 → 1.9.11

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.
@@ -0,0 +1,155 @@
1
+ import { UsageError } from "../args";
2
+ import { join, sha256 } from "./paths";
3
+ import {
4
+ type FileTree,
5
+ get,
6
+ MANIFEST,
7
+ type Manifest,
8
+ statusOf,
9
+ type Sync,
10
+ } from "./types";
11
+ import { writeManifest } from "./workflow-pull";
12
+
13
+ export interface WorkflowPushResult {
14
+ workflowId: string;
15
+ dir: string;
16
+ written: string[];
17
+ deleted: string[];
18
+ rev: number;
19
+ versionId: string;
20
+ }
21
+
22
+ export async function readManifest(sync: Sync, dir: string): Promise<Manifest> {
23
+ const bytes = await sync.files.read(join(dir, MANIFEST));
24
+ if (!bytes) {
25
+ throw new UsageError(
26
+ `no pulled workflow in ${dir}. Run \`asteroid workflow pull <workflow-id>\`, then push from the head folder it writes.`,
27
+ );
28
+ }
29
+ const manifest = JSON.parse(Buffer.from(bytes).toString("utf8")) as Manifest;
30
+ if (!manifest.head) {
31
+ throw new UsageError(
32
+ `${dir} holds a pinned workflow version, which is read-only. Push from a pulled head.`,
33
+ );
34
+ }
35
+ return manifest;
36
+ }
37
+
38
+ async function localChanges(sync: Sync, dir: string, manifest: Manifest) {
39
+ const local = (await sync.files.list(dir)).filter(
40
+ (path) => path !== MANIFEST,
41
+ );
42
+ const writes: { path: string; contentBase64: string; hash: string }[] = [];
43
+ for (const path of local) {
44
+ const bytes = await sync.files.read(join(dir, path));
45
+ if (!bytes) continue;
46
+ const hash = sha256(bytes);
47
+ if (manifest.files[path] === hash) continue;
48
+ writes.push({
49
+ path,
50
+ contentBase64: Buffer.from(bytes).toString("base64"),
51
+ hash,
52
+ });
53
+ }
54
+ const present = new Set(local);
55
+ const deletes = Object.keys(manifest.files).filter(
56
+ (path) => !present.has(path),
57
+ );
58
+ return { writes, deletes };
59
+ }
60
+
61
+ async function patch(
62
+ sync: Sync,
63
+ manifest: Manifest,
64
+ writes: { path: string; contentBase64: string }[],
65
+ deletes: string[],
66
+ ) {
67
+ try {
68
+ await sync.remote.json({
69
+ method: "PATCH",
70
+ path: `/agents/v2/workflows/${manifest.workflowId}/head/files`,
71
+ query: new URLSearchParams(),
72
+ json: { baseRev: manifest.rev, writes, deletes },
73
+ });
74
+ } catch (error) {
75
+ if (statusOf(error) !== 409) throw error;
76
+ throw new UsageError(
77
+ `the workflow head changed since your pull (rev ${manifest.rev}). Copy your edits aside, run \`asteroid workflow pull ${manifest.workflowId}\`, reapply them, then push again.`,
78
+ );
79
+ }
80
+ }
81
+
82
+ async function refresh(
83
+ sync: Sync,
84
+ dir: string,
85
+ manifest: Manifest,
86
+ pushed: Map<string, string>,
87
+ ) {
88
+ const tree = (await sync.remote.json(
89
+ get(`/agents/v2/workflows/${manifest.workflowId}/head/files`),
90
+ )) as FileTree;
91
+ const files: Record<string, string> = {};
92
+ for (const entry of tree.files) {
93
+ if (entry.content !== undefined) {
94
+ const bytes = Buffer.from(entry.content, "utf8");
95
+ files[entry.path] = sha256(bytes);
96
+ if (pushed.get(entry.path) !== files[entry.path])
97
+ await sync.files.write(join(dir, entry.path), bytes);
98
+ } else {
99
+ const local = await sync.files.read(join(dir, entry.path));
100
+ const hash = local
101
+ ? sha256(local)
102
+ : (pushed.get(entry.path) ?? entry.checksum);
103
+ if (hash) files[entry.path] = hash;
104
+ }
105
+ }
106
+ for (const path of await sync.files.list(dir)) {
107
+ if (path !== MANIFEST && !(path in files))
108
+ await sync.files.remove(join(dir, path));
109
+ }
110
+ const next = { ...manifest, versionId: tree.versionId, rev: tree.rev, files };
111
+ await writeManifest(sync, dir, next);
112
+ return next;
113
+ }
114
+
115
+ export async function pushWorkflow(
116
+ sync: Sync,
117
+ dir: string,
118
+ ): Promise<WorkflowPushResult> {
119
+ const manifest = await readManifest(sync, dir);
120
+ const { writes, deletes } = await localChanges(sync, dir, manifest);
121
+ const result = {
122
+ workflowId: manifest.workflowId,
123
+ dir,
124
+ written: writes.map((w) => w.path),
125
+ deleted: deletes,
126
+ };
127
+ if (writes.length === 0 && deletes.length === 0) {
128
+ return { ...result, rev: manifest.rev, versionId: manifest.versionId };
129
+ }
130
+ await patch(
131
+ sync,
132
+ manifest,
133
+ writes.map(({ path, contentBase64 }) => ({ path, contentBase64 })),
134
+ deletes,
135
+ );
136
+ const next = await refresh(
137
+ sync,
138
+ dir,
139
+ manifest,
140
+ new Map(writes.map((w) => [w.path, w.hash])),
141
+ );
142
+ return { ...result, rev: next.rev, versionId: next.versionId };
143
+ }
144
+
145
+ export function formatWorkflowPush(result: WorkflowPushResult): string {
146
+ if (result.written.length === 0 && result.deleted.length === 0) {
147
+ return `Nothing to push: ${result.dir}/ matches the head you pulled (rev ${result.rev}).`;
148
+ }
149
+ return [
150
+ `Pushed to workflow ${result.workflowId}: ${result.written.length} written, ${result.deleted.length} deleted.`,
151
+ ...result.written.map((path) => ` + ${path}`),
152
+ ...result.deleted.map((path) => ` - ${path}`),
153
+ `Head is now rev ${result.rev} (version ${result.versionId}).`,
154
+ ].join("\n");
155
+ }
package/src/types.ts CHANGED
@@ -16,6 +16,7 @@ export interface Option {
16
16
  description?: string;
17
17
  enum?: string[];
18
18
  explode?: boolean;
19
+ negatable?: boolean;
19
20
  }
20
21
 
21
22
  export interface BodySpec {
@@ -39,4 +40,5 @@ export interface CliCommand {
39
40
  binary: boolean;
40
41
  readOnly: boolean;
41
42
  destructive: boolean;
43
+ local?: boolean;
42
44
  }