@mawaru/sdk 0.11.0 → 0.14.1
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/_schemas/ai-manifest.test.ts +54 -59
- package/_schemas/ai-manifest.ts +38 -23
- package/_schemas/duplicate-selection.test.ts +235 -0
- package/_schemas/duplicate-selection.ts +48 -0
- package/_schemas/extract-component.test.ts +51 -2
- package/_schemas/extract-component.ts +4 -2
- package/_schemas/graph.test.ts +105 -86
- package/_schemas/graph.ts +20 -77
- package/_schemas/node.ts +4 -0
- package/_schemas/port-spec.test.ts +194 -113
- package/_schemas/port-spec.ts +95 -107
- package/dist/_cli/credentials.d.ts +14 -0
- package/dist/_cli/credentials.js +81 -0
- package/dist/_cli/login.d.ts +5 -0
- package/dist/_cli/login.js +82 -0
- package/dist/_cli/resumeSession.d.ts +21 -0
- package/dist/_cli/resumeSession.js +125 -0
- package/dist/_cli/sessionCommand.d.ts +3 -0
- package/dist/_cli/sessionCommand.js +55 -0
- package/dist/_schemas/ai-manifest.d.ts +12 -1
- package/dist/_schemas/ai-manifest.js +35 -20
- package/dist/_schemas/duplicate-selection.d.ts +12 -0
- package/dist/_schemas/duplicate-selection.js +34 -0
- package/dist/_schemas/extract-component.js +4 -2
- package/dist/_schemas/graph.d.ts +14 -12
- package/dist/_schemas/graph.js +21 -60
- package/dist/_schemas/hook-manifest.d.ts +2 -2
- package/dist/_schemas/node.d.ts +3 -2
- package/dist/_schemas/node.js +4 -0
- package/dist/_schemas/port-spec.d.ts +11 -6
- package/dist/_schemas/port-spec.js +72 -97
- package/dist/cli.js +50 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +1 -1
- package/dist/typegen.js +6 -6
- package/docs/development.md +5 -1
- package/index.ts +1 -0
- package/package.json +11 -18
- package/skills/create-loop/SKILL.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/echo/main.ts +1 -1
- package/templates/mawaru-runner.yml +1 -1
package/_schemas/port-spec.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { z } from "zod"
|
|
|
2
2
|
import type { GraphPort, JsonSchema, PortIo } from "./node.js"
|
|
3
3
|
|
|
4
4
|
// ポートの kind 別型システム(docs/tasks/wip/ポートのkind別仕様.md)。
|
|
5
|
-
// 型の決まり方は
|
|
6
|
-
//
|
|
5
|
+
// 型の決まり方は2分類:宣言(program / ai の in・out。human はビューの宣言を転写)/
|
|
6
|
+
// 導出(それ以外。接続元から伝播し、結果は ports.schema にキャッシュする)。
|
|
7
|
+
// mawaru がコードで型を固定するポートは無い
|
|
8
|
+
// (docs/tasks/wip/ポート型固定の撤去とセッション再開のポート属性化.md)
|
|
7
9
|
|
|
8
10
|
// 「まだ型が決まっていない」=未宣言を {} で表す(未接続・上流が未宣言・fan-in の型割れ)。
|
|
9
|
-
// 「何でも受ける」型は存在しない:
|
|
10
|
-
//
|
|
11
|
+
// 「何でも受ける」型は存在しない:program / ai の in は接続元の具体型を鏡映する
|
|
12
|
+
// (docs/tasks/wip/ポートスキーマのany撲滅.md)
|
|
11
13
|
export const UNDECLARED_SCHEMA: JsonSchema = {}
|
|
12
14
|
|
|
13
15
|
export const isUndeclaredSchema = (schema: JsonSchema): boolean =>
|
|
@@ -38,14 +40,12 @@ export const fileSchema = (): JsonSchema => ({
|
|
|
38
40
|
export const isFileSchema = (schema: JsonSchema): boolean =>
|
|
39
41
|
schema[FILE_SCHEMA_MARKER] === FILE_SCHEMA_TYPE
|
|
40
42
|
|
|
41
|
-
// ----
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
export const AI_INSTRUCTION_PORT_KEY = "instruction"
|
|
48
|
-
|
|
43
|
+
// ---- 差し戻しの指示のデフォルト型 ----
|
|
44
|
+
// 雛形(エディタの既定ポート・ais/create)と承認ビューの既定宣言が参照する
|
|
45
|
+
// **デフォルトであって強制ではない**:ユーザーはポートの宣言を自由に変えられ、
|
|
46
|
+
// 接続の成立は通常の型検証だけが決める。配管が効く根拠は files が file 型として
|
|
47
|
+
// 宣言されていることであって、キー名ではない(runner がマーカーで位置を見つけて
|
|
48
|
+
// materialize する)。スクリーンショットを添えて差し戻すケースがあるので文章だけにしない
|
|
49
49
|
export const instructionSchema = (): JsonSchema => ({
|
|
50
50
|
type: "object",
|
|
51
51
|
properties: {
|
|
@@ -138,38 +138,6 @@ export const dataFormatOf = (schema: JsonSchema): DataFormat | null => {
|
|
|
138
138
|
return { format: "list", columns }
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
// 封筒型 { data: T, description: string }。T は任意の JSON Schema、description は
|
|
142
|
-
// AI によるデータの説明(AI が必ず生成する)。AI の全出口に強制する
|
|
143
|
-
export const envelopeSchemaOf = (dataSchema: JsonSchema): JsonSchema => ({
|
|
144
|
-
type: "object",
|
|
145
|
-
properties: {
|
|
146
|
-
data: dataSchema,
|
|
147
|
-
description: { type: "string" },
|
|
148
|
-
},
|
|
149
|
-
required: ["data", "description"],
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
// 封筒型なら data のサブスキーマを返す(data キー必須の object。それ以外は null)
|
|
153
|
-
export const envelopeDataSchemaOf = (schema: JsonSchema): JsonSchema | null => {
|
|
154
|
-
if (schema.type !== "object") return null
|
|
155
|
-
const properties = (schema.properties ?? {}) as Record<string, JsonSchema>
|
|
156
|
-
const required = (schema.required ?? []) as string[]
|
|
157
|
-
const data = properties.data
|
|
158
|
-
if (!data || !required.includes("data")) return null
|
|
159
|
-
return data
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// AI の出口として妥当な封筒型か:data と description: string の両方が必須
|
|
163
|
-
export const isAiEnvelopeSchema = (schema: JsonSchema): boolean => {
|
|
164
|
-
if (envelopeDataSchemaOf(schema) === null) return false
|
|
165
|
-
const properties = (schema.properties ?? {}) as Record<string, JsonSchema>
|
|
166
|
-
const required = (schema.required ?? []) as string[]
|
|
167
|
-
return (
|
|
168
|
-
properties.description?.type === "string" &&
|
|
169
|
-
required.includes("description")
|
|
170
|
-
)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
141
|
// ---- kind 別ポート構成 ----
|
|
174
142
|
// human / wait は構成(数・key・io)が固定。program / ai は in 1つ+out 1つ以上
|
|
175
143
|
// (key 自由。複数 out=分岐。AIノードの複数出口(分岐).md)
|
|
@@ -187,22 +155,19 @@ const FIXED_PORT_KEYS: Record<string, { in: string[]; out: string[] }> = {
|
|
|
187
155
|
|
|
188
156
|
const FREE_OUT_KINDS = new Set(["program", "ai"])
|
|
189
157
|
|
|
190
|
-
// out
|
|
191
|
-
//
|
|
192
|
-
//
|
|
158
|
+
// out は自由(複数出口=分岐)。program は in の構成も固定(1口)だが、
|
|
159
|
+
// ai は in も自由(追加・削除・key 自由。セッション再開は key ではなくポートの
|
|
160
|
+
// resume 属性で決まる。ポート型固定の撤去とセッション再開のポート属性化.md)
|
|
193
161
|
const FIXED_IN_KEYS: Record<string, string[]> = {
|
|
194
162
|
program: ["in"],
|
|
195
|
-
ai: ["in", AI_INSTRUCTION_PORT_KEY],
|
|
196
163
|
}
|
|
197
164
|
|
|
198
165
|
export const fixedInKeysOf = (kind: string): string[] | null =>
|
|
199
166
|
FIXED_IN_KEYS[kind] ?? null
|
|
200
167
|
|
|
201
168
|
// config.json(manifest)の inputs 検査(適合なら null)。同じルールを二重に持たないため
|
|
202
|
-
// manifest
|
|
203
|
-
//
|
|
204
|
-
// constSchemaOf が焼き込む固定型なのでエディタ側で補え、in を2口に分ける前に同期された
|
|
205
|
-
// config.json もそのまま読める
|
|
169
|
+
// manifest 側はここを参照する。in の構成が固定の kind(program)だけが使う
|
|
170
|
+
// (ai の inputs は key 自由なので ai-manifest 側で件数と key の規則だけ見る)
|
|
206
171
|
export const manifestInKeysViolation = (
|
|
207
172
|
kind: string,
|
|
208
173
|
keys: string[],
|
|
@@ -265,13 +230,12 @@ export const kindPortsViolation = (
|
|
|
265
230
|
|
|
266
231
|
// ---- 型伝播(導出ポートの解決) ----
|
|
267
232
|
// 接続の両端は同じスキーマになる(値入力フォームのSchemaForm統一.md)。
|
|
268
|
-
// wait
|
|
269
|
-
//
|
|
270
|
-
// human の reject は未宣言のまま(段階2 で instruction エンベロープを宣言する)。
|
|
233
|
+
// wait は素通し(パラメトリック)ノード:in は接続元の out、out は in と同型。
|
|
234
|
+
// human はビューの宣言を転写("T" は素通しと同じ挙動。reject もビューの宣言)。
|
|
271
235
|
// 宣言 out(program / ai)は触らない。
|
|
272
|
-
// program / ai の in
|
|
273
|
-
//
|
|
274
|
-
// へも書き戻される。docs/tasks/wip/ポート編集のrepo書き戻し同期.md)
|
|
236
|
+
// program / ai の in は**全ての口が**接続鏡映(sticky):接続元 out が具体型のときだけ
|
|
237
|
+
// 上書きし、未接続・型割れ・未宣言は現スキーマ(宣言)を維持する(鏡映結果は repo の
|
|
238
|
+
// config.json へも書き戻される。docs/tasks/wip/ポート編集のrepo書き戻し同期.md)
|
|
275
239
|
|
|
276
240
|
// 複数の流入(fan-in)から型を1つに決める:未宣言を除いて具体型が1種類ならその型、
|
|
277
241
|
// 0種類なら未宣言。2種類以上は型が割れているので未宣言に落とす
|
|
@@ -302,6 +266,30 @@ type PropagationNode = {
|
|
|
302
266
|
}
|
|
303
267
|
type PortLink = { from_port_id: string; to_port_id: string }
|
|
304
268
|
|
|
269
|
+
// 「上流の out をそのままコピーする」(in)/「in と同型」(approve)を表す指示。
|
|
270
|
+
// 承認ビューのカタログ(@mawaru/common)の COPY_UPSTREAM と同じ値
|
|
271
|
+
export const COPY_UPSTREAM_SCHEMA = "T"
|
|
272
|
+
|
|
273
|
+
// ポートの型宣言:具体型か、上流からのコピー指示か
|
|
274
|
+
export type DeclaredPortSchema = JsonSchema | typeof COPY_UPSTREAM_SCHEMA
|
|
275
|
+
|
|
276
|
+
// 伝播のオプション。sdk は承認ビューのカタログを知らない(非公開。企業向けビューの
|
|
277
|
+
// キーが npm 公開物に載るのを避ける)ので、呼び出し元が解決して渡す
|
|
278
|
+
// → docs/tasks/wip/ポート型固定の撤去とセッション再開のポート属性化.md
|
|
279
|
+
export type PropagationOptions<N> = {
|
|
280
|
+
// human の in / approve / reject に転写する型(承認ビューの宣言)。null / undefined
|
|
281
|
+
// ならその node は対象外(human 以外)。上流ノードの kind は一切見ない。
|
|
282
|
+
// reject は具体型のみ(上流からのコピー指示は意味を持たない)
|
|
283
|
+
viewPortSchemasOf?: (node: N) =>
|
|
284
|
+
| {
|
|
285
|
+
in: DeclaredPortSchema
|
|
286
|
+
approve: DeclaredPortSchema
|
|
287
|
+
reject: JsonSchema
|
|
288
|
+
}
|
|
289
|
+
| null
|
|
290
|
+
| undefined
|
|
291
|
+
}
|
|
292
|
+
|
|
305
293
|
// end も導出ノード(各 in は上流から、out main は未宣言)。個別処理は
|
|
306
294
|
// resolvePortSchemas 内で行うが、初期スキーマを未宣言にするためここに含める。
|
|
307
295
|
// start も導出ノード:out main は接続先の in から逆向きに転写し、in は out と同型
|
|
@@ -313,46 +301,29 @@ const MIRROR_KINDS = new Set(["program", "ai"])
|
|
|
313
301
|
|
|
314
302
|
const isDerivedPort = (kind: string, port: Pick<GraphPort, "io" | "key">) =>
|
|
315
303
|
DERIVED_KINDS.has(kind) &&
|
|
316
|
-
(port.io === "in" ||
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// 接続バリデーションの「ai の in は常に許可」は維持)
|
|
321
|
-
// 宣言型で固定されるポート(導出も鏡映もしない)。差し戻しの指示エンベロープを
|
|
322
|
-
// human の出口と AI の入口の両方に焼き込むことで、両端が同じ具体型で一致する
|
|
323
|
-
const constSchemaOf = (
|
|
324
|
-
kind: string,
|
|
325
|
-
port: Pick<GraphPort, "io" | "key">,
|
|
326
|
-
): JsonSchema | null => {
|
|
327
|
-
if (kind === "human" && port.io === "out" && port.key === "reject") {
|
|
328
|
-
return instructionSchema()
|
|
329
|
-
}
|
|
330
|
-
if (
|
|
331
|
-
kind === "ai" &&
|
|
332
|
-
port.io === "in" &&
|
|
333
|
-
port.key === AI_INSTRUCTION_PORT_KEY
|
|
334
|
-
) {
|
|
335
|
-
return instructionSchema()
|
|
336
|
-
}
|
|
337
|
-
return null
|
|
338
|
-
}
|
|
304
|
+
(port.io === "in" ||
|
|
305
|
+
port.key === "main" ||
|
|
306
|
+
port.key === "approve" ||
|
|
307
|
+
port.key === "reject")
|
|
339
308
|
|
|
340
309
|
// port_id → 解決済みスキーマ。導出は接続を遡って固定点まで反復し、
|
|
341
|
-
// 未接続・純パラメトリック閉路・fan-in
|
|
310
|
+
// 未接続・純パラメトリック閉路・fan-in の型割れは未宣言に落とす。
|
|
311
|
+
// ai の in は nodes/ai/<dir>/config.json の inputs[key].schema 由来の宣言型が初期値で、
|
|
312
|
+
// 接続時は program と同じ鏡映で接続元と同値に同期される(どの in も対等。
|
|
313
|
+
// instruction への型の焼き込みは廃止)
|
|
342
314
|
export const resolvePortSchemas = <N extends PropagationNode>(
|
|
343
315
|
nodes: N[],
|
|
344
316
|
connections: PortLink[],
|
|
317
|
+
options?: PropagationOptions<N>,
|
|
345
318
|
): Map<string, JsonSchema> => {
|
|
346
319
|
const resolved = new Map<string, JsonSchema>()
|
|
347
320
|
const kindByPortId = new Map<string, string>()
|
|
348
321
|
for (const node of nodes) {
|
|
349
322
|
for (const port of node.ports) {
|
|
350
323
|
kindByPortId.set(port.id, node.kind)
|
|
351
|
-
const constSchema = constSchemaOf(node.kind, port)
|
|
352
324
|
resolved.set(
|
|
353
325
|
port.id,
|
|
354
|
-
|
|
355
|
-
(isDerivedPort(node.kind, port) ? UNDECLARED_SCHEMA : port.schema),
|
|
326
|
+
isDerivedPort(node.kind, port) ? UNDECLARED_SCHEMA : port.schema,
|
|
356
327
|
)
|
|
357
328
|
}
|
|
358
329
|
}
|
|
@@ -415,34 +386,50 @@ export const resolvePortSchemas = <N extends PropagationNode>(
|
|
|
415
386
|
}
|
|
416
387
|
const isMirror = MIRROR_KINDS.has(node.kind)
|
|
417
388
|
if (!DERIVED_KINDS.has(node.kind) && !isMirror) continue
|
|
418
|
-
// ai
|
|
419
|
-
|
|
389
|
+
// 鏡映(program / ai):全ての in が対象。具体型が流れてきたときだけ上書きする
|
|
390
|
+
// (宣言を未宣言で潰さない)。ai の in はどの口も対等(instruction の特別扱いは無い)
|
|
391
|
+
if (isMirror) {
|
|
392
|
+
for (const port of node.ports) {
|
|
393
|
+
if (port.io !== "in") continue
|
|
394
|
+
const sources = inbound.get(port.id) ?? []
|
|
395
|
+
const inSchema = mergeInboundSchemas(
|
|
396
|
+
sources.map((id) => resolved.get(id) ?? UNDECLARED_SCHEMA),
|
|
397
|
+
)
|
|
398
|
+
if (!isUndeclaredSchema(inSchema)) assign(port.id, inSchema)
|
|
399
|
+
}
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
// 導出(wait / human):in は上流から
|
|
403
|
+
const inPort = node.ports.find((p) => p.io === "in")
|
|
420
404
|
if (!inPort) continue
|
|
421
405
|
const sources = inbound.get(inPort.id) ?? []
|
|
422
406
|
const inSchema = mergeInboundSchemas(
|
|
423
407
|
sources.map((id) => resolved.get(id) ?? UNDECLARED_SCHEMA),
|
|
424
408
|
)
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
409
|
+
// 承認ビューの宣言を human のポートへそのまま転写する(上流ノードの kind は見ない)。
|
|
410
|
+
// "T" は「上流の out をコピー」(in)/「in と同型」(approve)。reject は具体型の宣言
|
|
411
|
+
// のみで、宣言が無ければ未宣言のまま(mawaru が型を焼き込むことはしない)。
|
|
412
|
+
// 上流の out は書き換えない:逆伝播すると fan-out 先のビューが食い違ったとき
|
|
413
|
+
// 型割れが config.json へ write-through され、元の出力宣言を黙って壊す。
|
|
414
|
+
// ズレは接続の型エラーで見せてユーザーの [修正する] で直す
|
|
415
|
+
const declared = options?.viewPortSchemasOf?.(node)
|
|
416
|
+
const effectiveIn =
|
|
417
|
+
declared && declared.in !== COPY_UPSTREAM_SCHEMA
|
|
418
|
+
? declared.in
|
|
419
|
+
: inSchema
|
|
420
|
+
assign(inPort.id, effectiveIn)
|
|
437
421
|
for (const port of node.ports) {
|
|
438
|
-
if (port.io
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
? (envelopeDataSchemaOf(inSchema) ?? UNDECLARED_SCHEMA)
|
|
443
|
-
: inSchema,
|
|
444
|
-
)
|
|
422
|
+
if (port.io !== "out" || !isDerivedPort(node.kind, port)) continue
|
|
423
|
+
if (node.kind === "human" && port.key === "reject") {
|
|
424
|
+
if (declared) assign(port.id, declared.reject)
|
|
425
|
+
continue
|
|
445
426
|
}
|
|
427
|
+
assign(
|
|
428
|
+
port.id,
|
|
429
|
+
declared && declared.approve !== COPY_UPSTREAM_SCHEMA
|
|
430
|
+
? declared.approve
|
|
431
|
+
: effectiveIn,
|
|
432
|
+
)
|
|
446
433
|
}
|
|
447
434
|
}
|
|
448
435
|
if (!changed) break
|
|
@@ -455,8 +442,9 @@ export const resolvePortSchemas = <N extends PropagationNode>(
|
|
|
455
442
|
export const propagateDerivedPorts = <N extends PropagationNode>(
|
|
456
443
|
nodes: N[],
|
|
457
444
|
connections: PortLink[],
|
|
445
|
+
options?: PropagationOptions<N>,
|
|
458
446
|
): N[] => {
|
|
459
|
-
const resolved = resolvePortSchemas(nodes, connections)
|
|
447
|
+
const resolved = resolvePortSchemas(nodes, connections, options)
|
|
460
448
|
return nodes.map((node) => {
|
|
461
449
|
let touched = false
|
|
462
450
|
const ports = node.ports.map((port) => {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type Credentials = {
|
|
2
|
+
refresh_token: string;
|
|
3
|
+
access_token: string;
|
|
4
|
+
supabase_url: string;
|
|
5
|
+
supabase_key: string;
|
|
6
|
+
api_url: string;
|
|
7
|
+
email: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const credentialsPath: () => string;
|
|
10
|
+
export declare const loadCredentials: () => Credentials | null;
|
|
11
|
+
export declare const saveCredentials: (credentials: Credentials) => string;
|
|
12
|
+
export declare const clearCredentials: () => boolean;
|
|
13
|
+
export declare const accessTokenFrom: (credentials: Credentials) => Promise<string>;
|
|
14
|
+
export declare const requireCredentials: () => Credentials;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export const credentialsPath = () => join(process.env.MAWARU_CONFIG_DIR ?? join(homedir(), ".mawaru"), "credentials.json");
|
|
5
|
+
export const loadCredentials = () => {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(readFileSync(credentialsPath(), "utf8"));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
// 0600 で保存する(refresh token は実質アカウントそのもの)
|
|
14
|
+
export const saveCredentials = (credentials) => {
|
|
15
|
+
const path = credentialsPath();
|
|
16
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
17
|
+
writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, {
|
|
18
|
+
mode: 0o600,
|
|
19
|
+
});
|
|
20
|
+
chmodSync(path, 0o600);
|
|
21
|
+
return path;
|
|
22
|
+
};
|
|
23
|
+
export const clearCredentials = () => {
|
|
24
|
+
try {
|
|
25
|
+
rmSync(credentialsPath());
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
// JWT の exp を見て、期限が近ければ refresh する。
|
|
33
|
+
// 検証はしない(署名を確かめるのはサーバの仕事)。ここでは「無駄な 401 を避ける」だけ
|
|
34
|
+
const expiresSoon = (accessToken) => {
|
|
35
|
+
try {
|
|
36
|
+
const payload = accessToken.split(".")[1];
|
|
37
|
+
if (!payload)
|
|
38
|
+
return true;
|
|
39
|
+
const { exp } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
40
|
+
if (typeof exp !== "number")
|
|
41
|
+
return true;
|
|
42
|
+
return exp * 1000 - Date.now() < 60_000;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
// API を叩くための access token。必要なら refresh して credentials を更新する
|
|
49
|
+
export const accessTokenFrom = async (credentials) => {
|
|
50
|
+
if (!expiresSoon(credentials.access_token))
|
|
51
|
+
return credentials.access_token;
|
|
52
|
+
const res = await fetch(`${credentials.supabase_url}/auth/v1/token?grant_type=refresh_token`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
apikey: credentials.supabase_key,
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({ refresh_token: credentials.refresh_token }),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error(`セッションの更新に失敗しました(HTTP ${res.status})。\`mawaru login\` でログインし直してください。`);
|
|
62
|
+
}
|
|
63
|
+
const body = (await res.json());
|
|
64
|
+
if (!body.access_token || !body.refresh_token) {
|
|
65
|
+
throw new Error("セッションの更新に失敗しました。`mawaru login` でログインし直してください。");
|
|
66
|
+
}
|
|
67
|
+
saveCredentials({
|
|
68
|
+
...credentials,
|
|
69
|
+
access_token: body.access_token,
|
|
70
|
+
refresh_token: body.refresh_token,
|
|
71
|
+
});
|
|
72
|
+
return body.access_token;
|
|
73
|
+
};
|
|
74
|
+
// ログイン必須のコマンドの入口。未ログインは案内して終了させる
|
|
75
|
+
export const requireCredentials = () => {
|
|
76
|
+
const credentials = loadCredentials();
|
|
77
|
+
if (!credentials) {
|
|
78
|
+
throw new Error("ログインしていません。`mawaru login` を実行してください。");
|
|
79
|
+
}
|
|
80
|
+
return credentials;
|
|
81
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { saveCredentials } from "./credentials.js";
|
|
5
|
+
// `gh auth login` と同じループバック方式(RFC 8252)。
|
|
6
|
+
// CLI が 127.0.0.1 の一時ポートで待ち受け、ブラウザで mawaru の /cli-auth を開く。
|
|
7
|
+
// 承認するとブラウザがループバック URL へ**遷移**してトークンを渡す
|
|
8
|
+
//(https のページから http へ fetch は混在コンテンツで塞がれるが、遷移なら通る)。
|
|
9
|
+
const APP_URL = process.env.MAWARU_APP_URL ?? "https://app.mawaru.ai";
|
|
10
|
+
const page = (title, message) => `<!doctype html><meta charset="utf-8"><title>${title}</title>` +
|
|
11
|
+
`<body style="font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0">` +
|
|
12
|
+
`<div style="text-align:center"><h1 style="font-size:1.25rem">${title}</h1>` +
|
|
13
|
+
`<p style="color:#666">${message}</p></div></body>`;
|
|
14
|
+
// ブラウザを開く(開けなければ URL を出して手で開いてもらう。依存は足さない)
|
|
15
|
+
const openBrowser = (url) => {
|
|
16
|
+
const [command, args] = process.platform === "darwin"
|
|
17
|
+
? ["open", [url]]
|
|
18
|
+
: process.platform === "win32"
|
|
19
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
20
|
+
: ["xdg-open", [url]];
|
|
21
|
+
try {
|
|
22
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// 何もしない(URL は既に印字済み)
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
export const runLogin = async (timeoutMs = 5 * 60 * 1000) => {
|
|
29
|
+
// 別のローカルプロセスが投げ込んだ応答を受け付けないための照合値
|
|
30
|
+
const state = randomUUID();
|
|
31
|
+
return await new Promise((resolve, reject) => {
|
|
32
|
+
const server = createServer((req, res) => {
|
|
33
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
34
|
+
if (url.pathname !== "/callback") {
|
|
35
|
+
res.writeHead(404).end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const params = url.searchParams;
|
|
39
|
+
const finish = (status, title, message) => {
|
|
40
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
41
|
+
res.end(page(title, message));
|
|
42
|
+
server.close();
|
|
43
|
+
};
|
|
44
|
+
if (params.get("state") !== state) {
|
|
45
|
+
finish(400, "承認に失敗しました", "リクエストが一致しませんでした。");
|
|
46
|
+
reject(new Error("承認の照合値(state)が一致しませんでした"));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const credentials = {
|
|
50
|
+
refresh_token: params.get("refresh_token") ?? "",
|
|
51
|
+
access_token: params.get("access_token") ?? "",
|
|
52
|
+
supabase_url: params.get("supabase_url") ?? "",
|
|
53
|
+
supabase_key: params.get("supabase_key") ?? "",
|
|
54
|
+
api_url: params.get("api_url") ?? "",
|
|
55
|
+
email: params.get("email") ?? "",
|
|
56
|
+
};
|
|
57
|
+
if (!credentials.refresh_token || !credentials.api_url) {
|
|
58
|
+
finish(400, "承認に失敗しました", "トークンを受け取れませんでした。");
|
|
59
|
+
reject(new Error("承認の応答にトークンが含まれていませんでした"));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const path = saveCredentials(credentials);
|
|
63
|
+
finish(200, "ログインしました", "このタブを閉じて、ターミナルに戻ってください。");
|
|
64
|
+
resolve({ email: credentials.email, path });
|
|
65
|
+
});
|
|
66
|
+
server.on("error", reject);
|
|
67
|
+
// ループバックのみに束ねる(他ホストから叩かれないように)
|
|
68
|
+
server.listen(0, "127.0.0.1", () => {
|
|
69
|
+
const { port } = server.address();
|
|
70
|
+
const authUrl = new URL("/cli-auth", APP_URL);
|
|
71
|
+
authUrl.searchParams.set("callback", `http://127.0.0.1:${port}/callback`);
|
|
72
|
+
authUrl.searchParams.set("state", state);
|
|
73
|
+
console.log(`ブラウザで承認してください: ${authUrl}`);
|
|
74
|
+
openBrowser(authUrl.toString());
|
|
75
|
+
});
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
server.close();
|
|
78
|
+
reject(new Error("承認がタイムアウトしました。もう一度お試しください。"));
|
|
79
|
+
}, timeoutMs);
|
|
80
|
+
server.on("close", () => clearTimeout(timer));
|
|
81
|
+
});
|
|
82
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Credentials } from "./credentials.js";
|
|
2
|
+
export type StepRef = {
|
|
3
|
+
tenantId: string;
|
|
4
|
+
runId: string;
|
|
5
|
+
stepId: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const parseStepUrl: (raw: string) => StepRef;
|
|
8
|
+
export declare const recordedCwds: (transcript: string) => {
|
|
9
|
+
all: string[];
|
|
10
|
+
last: string | null;
|
|
11
|
+
};
|
|
12
|
+
export declare const findProjectDir: (cwd: string) => string | null;
|
|
13
|
+
export type TranscriptRef = {
|
|
14
|
+
url: string;
|
|
15
|
+
session_id: string;
|
|
16
|
+
redacted?: number;
|
|
17
|
+
};
|
|
18
|
+
export declare const fetchTranscriptRef: (credentials: Credentials, ref: StepRef) => Promise<TranscriptRef>;
|
|
19
|
+
export declare const downloadTranscript: (url: string) => Promise<string>;
|
|
20
|
+
export declare const placeTranscript: (cwd: string, sessionId: string, transcript: string) => string;
|
|
21
|
+
export declare const spawnClaudeResume: (sessionId: string) => Promise<number>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { accessTokenFrom } from "./credentials.js";
|
|
6
|
+
// UI がコピーさせる web の URL から tenant / run / step を取り出す。
|
|
7
|
+
// 3つを1つの貼れる文字列で運ぶために URL の形にしている
|
|
8
|
+
export const parseStepUrl = (raw) => {
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(raw);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error(`URL として読めません: ${raw}`);
|
|
15
|
+
}
|
|
16
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
17
|
+
const runIndex = segments.indexOf("runs");
|
|
18
|
+
const stepIndex = segments.indexOf("steps");
|
|
19
|
+
const runId = runIndex >= 0 ? segments[runIndex + 1] : undefined;
|
|
20
|
+
const stepId = stepIndex >= 0 ? segments[stepIndex + 1] : undefined;
|
|
21
|
+
const tenantId = url.searchParams.get("tenant");
|
|
22
|
+
if (!runId || !stepId) {
|
|
23
|
+
throw new Error("run / step を含む URL ではありません(例: https://app.mawaru.ai/runs/<runId>/steps/<stepId>?tenant=<tenantId>)");
|
|
24
|
+
}
|
|
25
|
+
if (!tenantId) {
|
|
26
|
+
throw new Error("URL に ?tenant=<tenantId> がありません。run 詳細のコピーボタンで取得した URL をそのまま渡してください。");
|
|
27
|
+
}
|
|
28
|
+
return { tenantId, runId, stepId };
|
|
29
|
+
};
|
|
30
|
+
// transcript に記録された作業ディレクトリ(重複は畳み、最後に居た場所も返す)。
|
|
31
|
+
// **現在地がそこと違っていても resume 自体は通ってしまう**ので、人間に見せて確認を取る
|
|
32
|
+
export const recordedCwds = (transcript) => {
|
|
33
|
+
const all = [];
|
|
34
|
+
let last = null;
|
|
35
|
+
for (const line of transcript.split("\n")) {
|
|
36
|
+
if (!line)
|
|
37
|
+
continue;
|
|
38
|
+
let cwd;
|
|
39
|
+
try {
|
|
40
|
+
cwd = JSON.parse(line).cwd;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (typeof cwd !== "string" || cwd.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
if (!all.includes(cwd))
|
|
48
|
+
all.push(cwd);
|
|
49
|
+
last = cwd;
|
|
50
|
+
}
|
|
51
|
+
return { all, last };
|
|
52
|
+
};
|
|
53
|
+
const projectsRoot = () => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects");
|
|
54
|
+
// 置き先のディレクトリを **規則を再実装せず観測で決める**(agent-runner の
|
|
55
|
+
// _helpers/session.ts と同じ方針)。~/.claude/projects/*/*.jsonl の cwd を読み、
|
|
56
|
+
// いま居るディレクトリと一致するものを選ぶ
|
|
57
|
+
export const findProjectDir = (cwd) => {
|
|
58
|
+
const root = projectsRoot();
|
|
59
|
+
if (!existsSync(root))
|
|
60
|
+
return null;
|
|
61
|
+
for (const dir of readdirSync(root)) {
|
|
62
|
+
const path = join(root, dir);
|
|
63
|
+
let files;
|
|
64
|
+
try {
|
|
65
|
+
files = readdirSync(path).filter((f) => f.endsWith(".jsonl"));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
let head;
|
|
72
|
+
try {
|
|
73
|
+
head = readFileSync(join(path, file), "utf8");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (recordedCwds(head).all.includes(cwd))
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
};
|
|
84
|
+
// 段階3 のエンドポイントから署名 URL と session_id を得る
|
|
85
|
+
export const fetchTranscriptRef = async (credentials, ref) => {
|
|
86
|
+
const token = await accessTokenFrom(credentials);
|
|
87
|
+
const res = await fetch(`${credentials.api_url}/tenants/${ref.tenantId}/runs/${ref.runId}/steps/${ref.stepId}/transcript`, { headers: { Authorization: `Bearer ${token}` } });
|
|
88
|
+
if (res.status === 404) {
|
|
89
|
+
throw new Error("この step には退避されたセッションがありません(AI ノード以外か、退避前の実行です)。");
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
throw new Error(`transcript を取得できませんでした(HTTP ${res.status})`);
|
|
93
|
+
}
|
|
94
|
+
return (await res.json());
|
|
95
|
+
};
|
|
96
|
+
export const downloadTranscript = async (url) => {
|
|
97
|
+
const res = await fetch(url);
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
throw new Error(`transcript のダウンロードに失敗しました(HTTP ${res.status})`);
|
|
100
|
+
}
|
|
101
|
+
return await res.text();
|
|
102
|
+
};
|
|
103
|
+
// 置き先へ書き込む。ディレクトリが見つからないのは「この repo でまだ claude を
|
|
104
|
+
// 起動していない」ケースなので、観測できるようにしてから再実行してもらう
|
|
105
|
+
export const placeTranscript = (cwd, sessionId, transcript) => {
|
|
106
|
+
const dir = findProjectDir(cwd);
|
|
107
|
+
if (!dir) {
|
|
108
|
+
throw new Error("このディレクトリの Claude Code プロジェクトが見つかりません。" +
|
|
109
|
+
"ここで一度 `claude` を起動してから、もう一度実行してください。");
|
|
110
|
+
}
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
const file = join(dir, `${sessionId}.jsonl`);
|
|
113
|
+
writeFileSync(file, transcript);
|
|
114
|
+
return file;
|
|
115
|
+
};
|
|
116
|
+
// stdio を引き継いで claude を起動する(そのまま対話に入る)
|
|
117
|
+
export const spawnClaudeResume = (sessionId) => new Promise((resolve) => {
|
|
118
|
+
const child = spawn("claude", ["--resume", sessionId], { stdio: "inherit" });
|
|
119
|
+
child.on("error", () => {
|
|
120
|
+
console.error("claude を起動できませんでした。Claude Code をインストールしてから `claude --resume " +
|
|
121
|
+
`${sessionId}\` を実行してください。`);
|
|
122
|
+
resolve(1);
|
|
123
|
+
});
|
|
124
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
125
|
+
});
|