@awak-app/simy-cli 0.5.8 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/package.json +7 -3
- package/src/agent.js +70 -9
- package/src/auto-update.js +312 -35
- package/src/browser.js +24 -7
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/SKILL.md +172 -12
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/acceptance-evidence.md +130 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/change-boundary-matrix.md +20 -1
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/cross-repository-control.md +76 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/delivery-levels.md +76 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/state-and-receipts.md +15 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/scripts/check-acceptance.mjs +191 -0
- package/src/console/app.js +21 -2
- package/src/console/commands.js +3 -0
- package/src/desktop-host.js +220 -0
- package/src/desktop-process-lock.js +231 -0
- package/src/desktop-status.js +263 -0
- package/src/direct-executor.js +6 -0
- package/src/index.js +37 -11
- package/src/install-update.js +494 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, rm, writeFile, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { normalizeWebOrigin } from "./web-origin.js";
|
|
7
|
+
|
|
8
|
+
export const PROCESS_CONFLICT_CODE = "process_conflict";
|
|
9
|
+
export const PROCESS_CONFLICT_MESSAGE =
|
|
10
|
+
"Another SIMY background service is already running for this Web address. Stop the existing CLI daemon, then reopen SIMY CLI.";
|
|
11
|
+
|
|
12
|
+
export class SimyProcessConflictError extends Error {
|
|
13
|
+
constructor(message = PROCESS_CONFLICT_MESSAGE) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SimyProcessConflictError";
|
|
16
|
+
this.code = PROCESS_CONFLICT_CODE;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function acquireDesktopProcessLock(root, { pid = process.pid, alive = processAlive, identity = processStart, graceMs = 30_000, webOrigin = null } = {}) {
|
|
21
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
22
|
+
const directory = join(root, "desktop-app.lock");
|
|
23
|
+
const ownerFile = join(directory, "owner.json");
|
|
24
|
+
try {
|
|
25
|
+
await mkdir(directory, { mode: 0o700 });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code !== "EEXIST") throw error;
|
|
28
|
+
let owner;
|
|
29
|
+
try { owner = JSON.parse(await readFile(ownerFile, "utf8")); } catch { owner = {}; }
|
|
30
|
+
if (!Number.isSafeInteger(owner.pid) && Date.now() - (await stat(directory)).mtimeMs < graceMs) {
|
|
31
|
+
throw new SimyProcessConflictError("Another SIMY window is starting. Try opening it again shortly.");
|
|
32
|
+
}
|
|
33
|
+
if (Number.isSafeInteger(owner.pid) && alive(owner.pid) && (!owner.startedAt || owner.startedAt === await identity(owner.pid))) {
|
|
34
|
+
throw new SimyProcessConflictError("SIMY is already running. Open its menu bar icon.");
|
|
35
|
+
}
|
|
36
|
+
// Only one process may reclaim a dead owner; other contenders must retry.
|
|
37
|
+
const recovery = join(directory, "recovery");
|
|
38
|
+
try { await mkdir(recovery, { mode: 0o700 }); } catch {
|
|
39
|
+
const age = Date.now() - (await stat(recovery)).mtimeMs;
|
|
40
|
+
if (age < graceMs) throw new SimyProcessConflictError("SIMY is recovering a previous session. Try again shortly.");
|
|
41
|
+
await rm(recovery, { recursive: true });
|
|
42
|
+
await mkdir(recovery, { mode: 0o700 });
|
|
43
|
+
}
|
|
44
|
+
const latest = await readFile(ownerFile, "utf8").catch(() => "{}");
|
|
45
|
+
let current; try { current = JSON.parse(latest); } catch { current = {}; }
|
|
46
|
+
if (current.nonce !== owner.nonce) {
|
|
47
|
+
throw new SimyProcessConflictError("SIMY is starting in another window.");
|
|
48
|
+
}
|
|
49
|
+
await rm(directory, { recursive: true });
|
|
50
|
+
return acquireDesktopProcessLock(root, { pid, alive, identity, graceMs, webOrigin });
|
|
51
|
+
}
|
|
52
|
+
const nonce = randomBytes(32).toString("hex");
|
|
53
|
+
await writeFile(ownerFile, JSON.stringify({
|
|
54
|
+
pid,
|
|
55
|
+
nonce,
|
|
56
|
+
startedAt: await identity(pid),
|
|
57
|
+
webOrigin: normalizedOriginOrNull(webOrigin),
|
|
58
|
+
}), { mode: 0o600 });
|
|
59
|
+
return {
|
|
60
|
+
nonce,
|
|
61
|
+
async release() {
|
|
62
|
+
const owner = JSON.parse(await readFile(ownerFile, "utf8").catch(() => "{}"));
|
|
63
|
+
if (owner.pid === pid && owner.nonce === nonce) await rm(directory, { recursive: true, force: true });
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function assertNoDesktopHostForOrigin(
|
|
69
|
+
root,
|
|
70
|
+
webOrigin,
|
|
71
|
+
{ alive = processAlive, identity = processStart, graceMs = 30_000, now = Date.now } = {},
|
|
72
|
+
) {
|
|
73
|
+
const directory = join(root, "desktop-app.lock");
|
|
74
|
+
const ownerFile = join(directory, "owner.json");
|
|
75
|
+
let owner;
|
|
76
|
+
try {
|
|
77
|
+
owner = JSON.parse(await readFile(ownerFile, "utf8"));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const metadata = await stat(directory).catch(() => null);
|
|
80
|
+
if (metadata && now() - metadata.mtimeMs < graceMs) throw new SimyProcessConflictError();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!Number.isSafeInteger(owner?.pid) || !alive(owner.pid)) return;
|
|
84
|
+
if (owner.startedAt) {
|
|
85
|
+
let startedAt;
|
|
86
|
+
try {
|
|
87
|
+
startedAt = await identity(owner.pid);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new SimyProcessConflictError();
|
|
90
|
+
}
|
|
91
|
+
if (startedAt !== owner.startedAt) return;
|
|
92
|
+
}
|
|
93
|
+
const ownerOrigin = normalizedOriginOrNull(owner.webOrigin);
|
|
94
|
+
const requestedOrigin = normalizedOriginOrNull(webOrigin);
|
|
95
|
+
if (ownerOrigin && requestedOrigin && ownerOrigin !== requestedOrigin) return;
|
|
96
|
+
throw new SimyProcessConflictError();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function assertNoLegacyCliDaemon(
|
|
100
|
+
webOrigin,
|
|
101
|
+
{
|
|
102
|
+
processes = listSameUserProcesses,
|
|
103
|
+
verifyEntry = verifySimyCliEntry,
|
|
104
|
+
currentPid = process.pid,
|
|
105
|
+
} = {},
|
|
106
|
+
) {
|
|
107
|
+
const requestedOrigin = normalizeWebOrigin(webOrigin);
|
|
108
|
+
let candidates;
|
|
109
|
+
try {
|
|
110
|
+
candidates = await processes();
|
|
111
|
+
} catch {
|
|
112
|
+
throw new SimyProcessConflictError(
|
|
113
|
+
"SIMY could not verify whether another CLI service is running. Stop the existing CLI daemon, then reopen SIMY CLI.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
for (const processInfo of candidates) {
|
|
117
|
+
if (processInfo.pid === currentPid) continue;
|
|
118
|
+
const parsed = parseLegacyAgentArguments(processInfo.command ?? processInfo.args);
|
|
119
|
+
if (!parsed || !(await verifyEntry(parsed.entryPath))) continue;
|
|
120
|
+
if (parsed.webOrigin && parsed.webOrigin !== requestedOrigin) continue;
|
|
121
|
+
throw new SimyProcessConflictError();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseLegacyAgentArguments(args) {
|
|
126
|
+
const argv = Array.isArray(args) ? args : recoverProcessArguments(args);
|
|
127
|
+
if (argv.length < 2 || !["node", "nodejs"].includes(basename(argv[0]))) return null;
|
|
128
|
+
const entryIndex = argv.findIndex((value, index) => index > 0 && /(?:^|\/)src\/index\.js$/.test(value));
|
|
129
|
+
if (entryIndex < 1) return null;
|
|
130
|
+
const agentArgs = argv.slice(entryIndex + 1);
|
|
131
|
+
if (agentArgs.includes("--help") || agentArgs.includes("-h")) return null;
|
|
132
|
+
const subcommand = agentArgs.find((value, index) => {
|
|
133
|
+
if (value.startsWith("-")) return false;
|
|
134
|
+
const previous = agentArgs[index - 1];
|
|
135
|
+
return previous !== "--host" && previous !== "--port";
|
|
136
|
+
});
|
|
137
|
+
if (subcommand === "sqm" || subcommand === "update") return null;
|
|
138
|
+
let webOrigin = null;
|
|
139
|
+
const hostIndex = argv.indexOf("--host", entryIndex + 1);
|
|
140
|
+
const inlineHost = argv.slice(entryIndex + 1).find((value) => value.startsWith("--host="));
|
|
141
|
+
const explicitHost = hostIndex >= 0 ? argv[hostIndex + 1] : inlineHost?.slice("--host=".length);
|
|
142
|
+
if (explicitHost) webOrigin = normalizedOriginOrNull(explicitHost);
|
|
143
|
+
return { entryPath: argv[entryIndex], webOrigin };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function recoverProcessArguments(command) {
|
|
147
|
+
const raw = String(command || "");
|
|
148
|
+
// macOS ps does not retain shell quoting. Packaged app paths contain spaces, so
|
|
149
|
+
// recover the two known absolute path suffixes before tokenizing CLI flags.
|
|
150
|
+
const packaged = raw.match(/^\s*(\/.*?\/(?:node|nodejs))\s+(\/.*?\/src\/index\.js)(?=\s|$)(.*)$/);
|
|
151
|
+
if (packaged) return [packaged[1], packaged[2], ...splitProcessArguments(packaged[3])];
|
|
152
|
+
return splitProcessArguments(raw);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function splitProcessArguments(command) {
|
|
156
|
+
const values = [];
|
|
157
|
+
let value = "";
|
|
158
|
+
let quote = null;
|
|
159
|
+
let escaped = false;
|
|
160
|
+
for (const character of String(command || "")) {
|
|
161
|
+
if (escaped) {
|
|
162
|
+
value += character;
|
|
163
|
+
escaped = false;
|
|
164
|
+
} else if (character === "\\" && quote !== "'") {
|
|
165
|
+
escaped = true;
|
|
166
|
+
} else if (quote) {
|
|
167
|
+
if (character === quote) quote = null;
|
|
168
|
+
else value += character;
|
|
169
|
+
} else if (character === "'" || character === '"') {
|
|
170
|
+
quote = character;
|
|
171
|
+
} else if (/\s/.test(character)) {
|
|
172
|
+
if (value) values.push(value);
|
|
173
|
+
value = "";
|
|
174
|
+
} else {
|
|
175
|
+
value += character;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (escaped) value += "\\";
|
|
179
|
+
if (value) values.push(value);
|
|
180
|
+
return values;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function validateDesktopSupervisor(root, nonce) {
|
|
184
|
+
if (!nonce) return false;
|
|
185
|
+
const owner = JSON.parse(await readFile(join(root, "desktop-app.lock", "owner.json"), "utf8").catch(() => "{}"));
|
|
186
|
+
return owner.nonce === nonce && Number.isSafeInteger(owner.pid) && processAlive(owner.pid) && owner.startedAt === await processStart(owner.pid);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function processStart(pid) {
|
|
190
|
+
const { stdout } = await promisify(execFile)("/bin/ps", ["-p", String(pid), "-o", "lstart="], { timeout: 2_000 });
|
|
191
|
+
return stdout.trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function processAlive(pid) {
|
|
195
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code !== "ESRCH"; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function listSameUserProcesses() {
|
|
199
|
+
const uid = process.getuid?.();
|
|
200
|
+
if (!Number.isSafeInteger(uid)) return [];
|
|
201
|
+
const { stdout } = await promisify(execFile)("/bin/ps", ["-ww", "-axo", "pid=,uid=,command="], {
|
|
202
|
+
timeout: 2_000,
|
|
203
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
204
|
+
});
|
|
205
|
+
const processes = [];
|
|
206
|
+
for (const line of stdout.split("\n")) {
|
|
207
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
|
|
208
|
+
if (!match || Number(match[2]) !== uid) continue;
|
|
209
|
+
processes.push({ pid: Number(match[1]), command: match[3] });
|
|
210
|
+
}
|
|
211
|
+
return processes;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function verifySimyCliEntry(entryPath) {
|
|
215
|
+
try {
|
|
216
|
+
const resolved = await realpath(entryPath);
|
|
217
|
+
const packageJson = JSON.parse(await readFile(join(dirname(dirname(resolved)), "package.json"), "utf8"));
|
|
218
|
+
return packageJson?.name === "@awak-app/simy-cli";
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizedOriginOrNull(value) {
|
|
225
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
226
|
+
try {
|
|
227
|
+
return normalizeWebOrigin(value);
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { inspectBackendExecutable } from "./backend-executable.js";
|
|
4
|
+
import { isSessionValid } from "./session-store.js";
|
|
5
|
+
|
|
6
|
+
const CODEX_STATUS_CACHE_MS = 30_000;
|
|
7
|
+
const MAX_DETAIL_LENGTH = 240;
|
|
8
|
+
const MAX_LABEL_LENGTH = 120;
|
|
9
|
+
const MAX_STATUS_ITEMS = 50;
|
|
10
|
+
const MAX_PROBE_OUTPUT = 4_096;
|
|
11
|
+
const INACTIVE_STATES = new Set([
|
|
12
|
+
"succeeded",
|
|
13
|
+
"failed",
|
|
14
|
+
"stopped",
|
|
15
|
+
"cancelled",
|
|
16
|
+
"canceled",
|
|
17
|
+
"complete",
|
|
18
|
+
"completed",
|
|
19
|
+
"merge_ready",
|
|
20
|
+
"pr_ready_for_review",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export function createConnectionStatusTracker({ now = () => new Date() } = {}) {
|
|
24
|
+
let lastSuccessAt = null;
|
|
25
|
+
let lastAttemptAt = null;
|
|
26
|
+
let state = "unknown";
|
|
27
|
+
let error = null;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
attempt() {
|
|
31
|
+
lastAttemptAt = isoNow(now);
|
|
32
|
+
state = state === "online" ? "online" : "connecting";
|
|
33
|
+
error = null;
|
|
34
|
+
},
|
|
35
|
+
success() {
|
|
36
|
+
lastSuccessAt = isoNow(now);
|
|
37
|
+
state = "online";
|
|
38
|
+
error = null;
|
|
39
|
+
},
|
|
40
|
+
failure(cause) {
|
|
41
|
+
state = isUnauthorizedError(cause) ? "unauthorized" : "offline";
|
|
42
|
+
error = safeStatusDetail(cause);
|
|
43
|
+
return state;
|
|
44
|
+
},
|
|
45
|
+
snapshot({ authenticated = false } = {}) {
|
|
46
|
+
const visibleState = authenticated
|
|
47
|
+
? state === "unknown"
|
|
48
|
+
? "connecting"
|
|
49
|
+
: state
|
|
50
|
+
: state === "unauthorized"
|
|
51
|
+
? "unauthorized"
|
|
52
|
+
: "unknown";
|
|
53
|
+
return {
|
|
54
|
+
state: visibleState,
|
|
55
|
+
lastSuccessAt,
|
|
56
|
+
lastAttemptAt,
|
|
57
|
+
error: visibleState === "unknown" ? null : error,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function desktopAuthStatus(session, { now = Date.now(), webOrigin = null } = {}) {
|
|
64
|
+
const exists = Boolean(session);
|
|
65
|
+
const valid = isSessionValid(session, now, webOrigin);
|
|
66
|
+
return {
|
|
67
|
+
state: valid ? "signed_in" : exists ? "expired" : "signed_out",
|
|
68
|
+
expiresAt: typeof session?.expires_at === "string" ? session.expires_at : null,
|
|
69
|
+
userLabel: sessionUserLabel(session),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function desktopTaskStatus(registries, { maxItems = MAX_STATUS_ITEMS } = {}) {
|
|
74
|
+
const tasks = registries.flatMap((registry, registryIndex) =>
|
|
75
|
+
typeof registry?.list === "function"
|
|
76
|
+
? registry.list().map((task) => taskStatusItem(task, registryIndex))
|
|
77
|
+
: [],
|
|
78
|
+
);
|
|
79
|
+
tasks.sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
|
|
80
|
+
return {
|
|
81
|
+
activeCount: tasks.filter((task) => !INACTIVE_STATES.has(task.state)).length,
|
|
82
|
+
items: tasks.slice(0, maxItems).map(({ updatedAt: _updatedAt, ...item }) => item),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createCodexStatusInspector({
|
|
87
|
+
cacheMs = CODEX_STATUS_CACHE_MS,
|
|
88
|
+
inspect = inspectCodexStatus,
|
|
89
|
+
now = () => Date.now(),
|
|
90
|
+
inspectOptions = {},
|
|
91
|
+
} = {}) {
|
|
92
|
+
let cached = null;
|
|
93
|
+
let cachedAt = 0;
|
|
94
|
+
let inFlight = null;
|
|
95
|
+
return async ({ force = false } = {}) => {
|
|
96
|
+
const current = now();
|
|
97
|
+
if (!force && cached && current - cachedAt < cacheMs) return structuredClone(cached);
|
|
98
|
+
if (inFlight) return structuredClone(await inFlight);
|
|
99
|
+
inFlight = Promise.resolve(inspect({ ...inspectOptions, now: () => new Date(now()) }))
|
|
100
|
+
.then((result) => {
|
|
101
|
+
cached = result;
|
|
102
|
+
cachedAt = now();
|
|
103
|
+
return result;
|
|
104
|
+
})
|
|
105
|
+
.finally(() => {
|
|
106
|
+
inFlight = null;
|
|
107
|
+
});
|
|
108
|
+
return structuredClone(await inFlight);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function inspectCodexStatus({
|
|
113
|
+
inspectExecutable = inspectBackendExecutable,
|
|
114
|
+
spawnImpl = spawn,
|
|
115
|
+
timeoutMs = 2_000,
|
|
116
|
+
now = () => new Date(),
|
|
117
|
+
} = {}) {
|
|
118
|
+
const checkedAt = isoNow(now);
|
|
119
|
+
let executable;
|
|
120
|
+
try {
|
|
121
|
+
executable = await inspectExecutable("codex", { spawnImpl, timeoutMs });
|
|
122
|
+
} catch {
|
|
123
|
+
return codexStatus("unknown", null, checkedAt, "Codex availability could not be checked.");
|
|
124
|
+
}
|
|
125
|
+
const version = executable?.installed_version ?? null;
|
|
126
|
+
if (!executable?.available) {
|
|
127
|
+
return codexStatus("missing", version, checkedAt, "Codex is not installed or cannot be started.");
|
|
128
|
+
}
|
|
129
|
+
if (!executable.compatible) {
|
|
130
|
+
return codexStatus(
|
|
131
|
+
executable.status === "outdated" ? "outdated" : "unknown",
|
|
132
|
+
version,
|
|
133
|
+
checkedAt,
|
|
134
|
+
executable.status === "outdated"
|
|
135
|
+
? `Codex ${version || "installed version"} is older than the supported minimum.`
|
|
136
|
+
: "The installed Codex version could not be verified.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const probe = await runLoginStatus(executable.executable, { spawnImpl, timeoutMs });
|
|
141
|
+
if (probe.kind === "signed_in") {
|
|
142
|
+
return codexStatus("ready", version, checkedAt, "Codex is installed and signed in.");
|
|
143
|
+
}
|
|
144
|
+
if (probe.kind === "signed_out") {
|
|
145
|
+
return codexStatus("signed_out", version, checkedAt, "Codex is installed but signed out.");
|
|
146
|
+
}
|
|
147
|
+
return codexStatus("unknown", version, checkedAt, "Codex login status could not be verified.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function safeStatusDetail(cause) {
|
|
151
|
+
const source = cause instanceof Error ? cause.message : String(cause || "Connection failed.");
|
|
152
|
+
if (isUnauthorizedError(cause)) return "SIMY authorization was rejected.";
|
|
153
|
+
if (/\b(?:abort|timed? ?out|timeout)\b/i.test(source)) return "SIMY connection timed out.";
|
|
154
|
+
if (/\b(?:fetch failed|network|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN)\b/i.test(source)) {
|
|
155
|
+
return "SIMY Web could not be reached.";
|
|
156
|
+
}
|
|
157
|
+
return bounded("SIMY connection failed.", MAX_DETAIL_LENGTH);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isUnauthorizedError(error) {
|
|
161
|
+
const status = Number(error?.status ?? error?.statusCode);
|
|
162
|
+
if (status === 401 || status === 403) return true;
|
|
163
|
+
return /\b(?:401|403|unauthori[sz]ed|forbidden|session expired|invalid token|token expired)\b/i.test(
|
|
164
|
+
error instanceof Error ? error.message : String(error || ""),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function taskStatusItem(task, registryIndex) {
|
|
169
|
+
const state = bounded(
|
|
170
|
+
String(
|
|
171
|
+
task?.status ||
|
|
172
|
+
task?.snapshot?.state ||
|
|
173
|
+
task?.state ||
|
|
174
|
+
task?.control_state ||
|
|
175
|
+
task?.controlState ||
|
|
176
|
+
"unknown",
|
|
177
|
+
),
|
|
178
|
+
48,
|
|
179
|
+
);
|
|
180
|
+
return {
|
|
181
|
+
id: bounded(String(task?.id || `unknown-${registryIndex}`), MAX_LABEL_LENGTH),
|
|
182
|
+
label: bounded(
|
|
183
|
+
String(
|
|
184
|
+
task?.request?.requirement ||
|
|
185
|
+
task?.instruction ||
|
|
186
|
+
task?.snapshot?.charter?.requirement ||
|
|
187
|
+
task?.id ||
|
|
188
|
+
"Task",
|
|
189
|
+
),
|
|
190
|
+
MAX_LABEL_LENGTH,
|
|
191
|
+
),
|
|
192
|
+
state,
|
|
193
|
+
updatedAt:
|
|
194
|
+
Date.parse(task?.updated_at || task?.snapshot?.updated_at || task?.startedAt || 0) || 0,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sessionUserLabel(session) {
|
|
199
|
+
if (!session) return null;
|
|
200
|
+
for (const key of ["user_label", "display_name", "email", "account_id", "auth_user_id"]) {
|
|
201
|
+
if (typeof session[key] === "string" && session[key].trim()) {
|
|
202
|
+
return bounded(session[key].trim(), MAX_LABEL_LENGTH);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function runLoginStatus(command, { spawnImpl, timeoutMs }) {
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
let settled = false;
|
|
211
|
+
let output = "";
|
|
212
|
+
let timeout;
|
|
213
|
+
let child;
|
|
214
|
+
const finish = (kind) => {
|
|
215
|
+
if (settled) return;
|
|
216
|
+
settled = true;
|
|
217
|
+
if (timeout) clearTimeout(timeout);
|
|
218
|
+
resolve({ kind });
|
|
219
|
+
};
|
|
220
|
+
try {
|
|
221
|
+
child = spawnImpl(command, ["login", "status"], {
|
|
222
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
finish("unknown");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const append = (chunk) => {
|
|
229
|
+
if (output.length >= MAX_PROBE_OUTPUT) return;
|
|
230
|
+
output += String(chunk).slice(0, MAX_PROBE_OUTPUT - output.length);
|
|
231
|
+
};
|
|
232
|
+
child.stdout?.on?.("data", append);
|
|
233
|
+
child.stderr?.on?.("data", append);
|
|
234
|
+
timeout = setTimeout(() => {
|
|
235
|
+
child.kill?.("SIGTERM");
|
|
236
|
+
finish("unknown");
|
|
237
|
+
}, timeoutMs);
|
|
238
|
+
child.on?.("error", () => finish("unknown"));
|
|
239
|
+
child.on?.("close", (code) => {
|
|
240
|
+
if (/not logged in|signed out|login required|please log in/i.test(output)) {
|
|
241
|
+
finish("signed_out");
|
|
242
|
+
} else if (code === 0 && /logged in|signed in/i.test(output)) {
|
|
243
|
+
finish("signed_in");
|
|
244
|
+
} else {
|
|
245
|
+
finish("unknown");
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function codexStatus(state, version, checkedAt, detail) {
|
|
252
|
+
return { state, version, checkedAt, detail };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isoNow(now) {
|
|
256
|
+
const value = now();
|
|
257
|
+
return (value instanceof Date ? value : new Date(value)).toISOString();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function bounded(value, limit) {
|
|
261
|
+
const clean = String(value || "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
262
|
+
return clean.length <= limit ? clean : `${clean.slice(0, limit - 1)}…`;
|
|
263
|
+
}
|
package/src/direct-executor.js
CHANGED
|
@@ -17,6 +17,12 @@ export class DirectTaskRegistry {
|
|
|
17
17
|
get(taskId) {
|
|
18
18
|
return this.#tasks.get(taskId) ?? null;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
list() {
|
|
22
|
+
return [...this.#tasks.values()].sort(
|
|
23
|
+
(left, right) => Date.parse(right.updated_at || 0) - Date.parse(left.updated_at || 0),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export function createDirectTask({
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
6
8
|
|
|
7
9
|
import { startAgent } from "./agent.js";
|
|
8
10
|
import {
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
import { prepareDaemonWorkflows } from "./sqm/workflow-onboarding.js";
|
|
23
25
|
import { startWorkflowUpdates } from "./sqm/workflow-update.js";
|
|
24
26
|
import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
|
|
27
|
+
import { assertNoDesktopHostForOrigin } from "./desktop-process-lock.js";
|
|
25
28
|
|
|
26
29
|
const argv = process.argv.slice(2);
|
|
27
30
|
const args = new Set(argv);
|
|
@@ -29,12 +32,22 @@ const dispatchArgv = argv.filter((value) => value !== "--no-auto-update");
|
|
|
29
32
|
const entryPath = fileURLToPath(import.meta.url);
|
|
30
33
|
const packageInfo = await readCliPackageInfo();
|
|
31
34
|
const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
|
|
35
|
+
let configuredWebOrigin;
|
|
36
|
+
try {
|
|
37
|
+
configuredWebOrigin = resolveWebOrigin(readOption(argv, "--host"));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
40
|
+
process.exitCode = 2;
|
|
41
|
+
}
|
|
32
42
|
|
|
33
|
-
if (
|
|
43
|
+
if (!configuredWebOrigin) {
|
|
44
|
+
// Invalid host input is reported above before update discovery or startup.
|
|
45
|
+
} else if (dispatchArgv[0] === "update") {
|
|
34
46
|
const checkOnly = args.has("--check");
|
|
35
47
|
const result = await runCliUpdateGate({
|
|
36
48
|
argv,
|
|
37
49
|
packageInfo,
|
|
50
|
+
webOrigin: configuredWebOrigin,
|
|
38
51
|
mode: checkOnly ? "check" : "update",
|
|
39
52
|
disabled: updatesDisabled,
|
|
40
53
|
allowMajor: args.has("--yes"),
|
|
@@ -46,16 +59,17 @@ if (dispatchArgv[0] === "update") {
|
|
|
46
59
|
argv,
|
|
47
60
|
packageInfo,
|
|
48
61
|
entryPath,
|
|
62
|
+
webOrigin: configuredWebOrigin,
|
|
49
63
|
disabled: updatesDisabled,
|
|
50
64
|
});
|
|
51
65
|
if (startupUpdate.reexecuted) {
|
|
52
66
|
process.exitCode = startupUpdate.exitCode;
|
|
53
67
|
} else {
|
|
54
|
-
await runCommand();
|
|
68
|
+
await runCommand(startupUpdate.installMode);
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
71
|
|
|
58
|
-
async function runCommand() {
|
|
72
|
+
async function runCommand(installMode) {
|
|
59
73
|
if (dispatchArgv[0] === "sqm") {
|
|
60
74
|
try {
|
|
61
75
|
const { runSqmCommand } = await import("./sqm/command.js");
|
|
@@ -66,6 +80,7 @@ async function runCommand() {
|
|
|
66
80
|
argv,
|
|
67
81
|
packageInfo,
|
|
68
82
|
entryPath,
|
|
83
|
+
webOrigin: configuredWebOrigin,
|
|
69
84
|
mode: "incompatible",
|
|
70
85
|
disabled: updatesDisabled,
|
|
71
86
|
allowMajor: args.has("--yes"),
|
|
@@ -95,7 +110,7 @@ async function runCommand() {
|
|
|
95
110
|
simy sqm lint Validate module reliability before publication
|
|
96
111
|
simy sqm replay Replay modules against labelled historical snapshots
|
|
97
112
|
simy update --check Check the installed and latest stable CLI versions
|
|
98
|
-
simy update Install an eligible stable CLI update
|
|
113
|
+
simy update ${installMode === "macos_installer" ? "Show Apple Silicon update status and installer action" : "Install an eligible stable CLI update"}
|
|
99
114
|
|
|
100
115
|
Options:
|
|
101
116
|
--daemon, --deamon Run detached in the background
|
|
@@ -107,7 +122,7 @@ Options:
|
|
|
107
122
|
--no-workflow-prompt Skip the daemon startup workflow-installation prompt
|
|
108
123
|
--no-auto-update Disable update checks and automatic installation
|
|
109
124
|
|
|
110
|
-
${autoUpdateHelpText()}
|
|
125
|
+
${autoUpdateHelpText({ installMode, webOrigin: configuredWebOrigin })}
|
|
111
126
|
`);
|
|
112
127
|
return;
|
|
113
128
|
}
|
|
@@ -119,9 +134,11 @@ async function runLocalAgent() {
|
|
|
119
134
|
const daemon = args.has("--daemon") || args.has("--deamon");
|
|
120
135
|
const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
|
|
121
136
|
const port = readPort(argv);
|
|
122
|
-
|
|
137
|
+
const webOrigin = configuredWebOrigin;
|
|
138
|
+
const sessionRoot = process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
139
|
+
|
|
123
140
|
try {
|
|
124
|
-
|
|
141
|
+
await prepareDaemonWorkflows({ argv, daemon });
|
|
125
142
|
} catch (error) {
|
|
126
143
|
console.error(error instanceof Error ? error.message : String(error));
|
|
127
144
|
process.exitCode = 2;
|
|
@@ -129,7 +146,7 @@ async function runLocalAgent() {
|
|
|
129
146
|
}
|
|
130
147
|
|
|
131
148
|
try {
|
|
132
|
-
await
|
|
149
|
+
await assertNoDesktopHostForOrigin(sessionRoot, webOrigin);
|
|
133
150
|
} catch (error) {
|
|
134
151
|
console.error(error instanceof Error ? error.message : String(error));
|
|
135
152
|
process.exitCode = 2;
|
|
@@ -151,6 +168,7 @@ async function runLocalAgent() {
|
|
|
151
168
|
daemon,
|
|
152
169
|
interactive,
|
|
153
170
|
requestedPort: port,
|
|
171
|
+
webOrigin,
|
|
154
172
|
disabled: args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0",
|
|
155
173
|
dependencies: { packageInfo },
|
|
156
174
|
});
|
|
@@ -168,15 +186,23 @@ async function runLocalAgent() {
|
|
|
168
186
|
await closeAgent();
|
|
169
187
|
};
|
|
170
188
|
installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
|
|
171
|
-
await writeUpdateHandoffFromEnvironment({
|
|
189
|
+
await writeUpdateHandoffFromEnvironment({
|
|
190
|
+
version: packageInfo.version,
|
|
191
|
+
port: agent.port,
|
|
192
|
+
webOrigin,
|
|
193
|
+
installMode: updateManager.snapshot().install_mode,
|
|
194
|
+
});
|
|
172
195
|
stopWorkflowUpdates = startWorkflowUpdates({ enabled: daemon && !updatesDisabled });
|
|
173
196
|
updateManager.start({
|
|
174
197
|
registry: agent.registry,
|
|
175
|
-
restartDaemon: async (targetVersion) => {
|
|
198
|
+
restartDaemon: async (targetVersion, installation) => {
|
|
176
199
|
await restartDaemonProcess({
|
|
177
|
-
entryPath: fileURLToPath(import.meta.url),
|
|
200
|
+
entryPath: installation?.entryPath || fileURLToPath(import.meta.url),
|
|
201
|
+
executablePath: installation?.executablePath,
|
|
178
202
|
argv: process.argv.slice(2),
|
|
179
203
|
targetVersion,
|
|
204
|
+
expectedWebOrigin: webOrigin,
|
|
205
|
+
expectedInstallMode: updateManager.snapshot().install_mode,
|
|
180
206
|
});
|
|
181
207
|
await shutdownAgent();
|
|
182
208
|
},
|