@indecisivedev/openplan 0.2.0-alpha.2
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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/access-policy.d.ts +35 -0
- package/dist/access-policy.js +107 -0
- package/dist/access-policy.js.map +1 -0
- package/dist/access-store.d.ts +33 -0
- package/dist/access-store.js +212 -0
- package/dist/access-store.js.map +1 -0
- package/dist/admin.d.ts +1 -0
- package/dist/admin.js +13 -0
- package/dist/admin.js.map +1 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +867 -0
- package/dist/api.js.map +1 -0
- package/dist/bootstrap-admin.d.ts +1 -0
- package/dist/bootstrap-admin.js +35 -0
- package/dist/bootstrap-admin.js.map +1 -0
- package/dist/browser-security.d.ts +5 -0
- package/dist/browser-security.js +53 -0
- package/dist/browser-security.js.map +1 -0
- package/dist/celld-quickstart.d.ts +51 -0
- package/dist/celld-quickstart.js +1175 -0
- package/dist/celld-quickstart.js.map +1 -0
- package/dist/cli-api-client.d.ts +78 -0
- package/dist/cli-api-client.js +272 -0
- package/dist/cli-api-client.js.map +1 -0
- package/dist/cli-credentials.d.ts +47 -0
- package/dist/cli-credentials.js +447 -0
- package/dist/cli-credentials.js.map +1 -0
- package/dist/cli-endpoint.d.ts +19 -0
- package/dist/cli-endpoint.js +56 -0
- package/dist/cli-endpoint.js.map +1 -0
- package/dist/cli-program.d.ts +11 -0
- package/dist/cli-program.js +934 -0
- package/dist/cli-program.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -0
- package/dist/client-ip.d.ts +4 -0
- package/dist/client-ip.js +49 -0
- package/dist/client-ip.js.map +1 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +113 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.d.ts +3 -0
- package/dist/crypto.js +11 -0
- package/dist/crypto.js.map +1 -0
- package/dist/database.d.ts +15 -0
- package/dist/database.js +155 -0
- package/dist/database.js.map +1 -0
- package/dist/db.d.ts +31 -0
- package/dist/db.js +388 -0
- package/dist/db.js.map +1 -0
- package/dist/drafts.d.ts +43 -0
- package/dist/drafts.js +90 -0
- package/dist/drafts.js.map +1 -0
- package/dist/feature-settings.d.ts +7 -0
- package/dist/feature-settings.js +32 -0
- package/dist/feature-settings.js.map +1 -0
- package/dist/generated/annotation-client.d.ts +1 -0
- package/dist/generated/annotation-client.js +2 -0
- package/dist/generated/annotation-client.js.map +1 -0
- package/dist/html-policy.d.ts +15 -0
- package/dist/html-policy.js +172 -0
- package/dist/html-policy.js.map +1 -0
- package/dist/ids.d.ts +2 -0
- package/dist/ids.js +10 -0
- package/dist/ids.js.map +1 -0
- package/dist/local-auth.d.ts +36 -0
- package/dist/local-auth.js +215 -0
- package/dist/local-auth.js.map +1 -0
- package/dist/maintenance.d.ts +1 -0
- package/dist/maintenance.js +18 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/oidc.d.ts +39 -0
- package/dist/oidc.js +194 -0
- package/dist/oidc.js.map +1 -0
- package/dist/public-url.d.ts +17 -0
- package/dist/public-url.js +96 -0
- package/dist/public-url.js.map +1 -0
- package/dist/rate-limit.d.ts +25 -0
- package/dist/rate-limit.js +55 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/reconcile.d.ts +5 -0
- package/dist/reconcile.js +18 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/render-annotations.d.ts +10 -0
- package/dist/render-annotations.js +73 -0
- package/dist/render-annotations.js.map +1 -0
- package/dist/render-web.d.ts +74 -0
- package/dist/render-web.js +439 -0
- package/dist/render-web.js.map +1 -0
- package/dist/render.d.ts +4 -0
- package/dist/render.js +85 -0
- package/dist/render.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +26 -0
- package/dist/server.js.map +1 -0
- package/dist/shoo.d.ts +26 -0
- package/dist/shoo.js +92 -0
- package/dist/shoo.js.map +1 -0
- package/dist/sqlite-migrations.d.ts +6 -0
- package/dist/sqlite-migrations.js +214 -0
- package/dist/sqlite-migrations.js.map +1 -0
- package/dist/storage.d.ts +14 -0
- package/dist/storage.js +86 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/upload-state.d.ts +19 -0
- package/dist/upload-state.js +118 -0
- package/dist/upload-state.js.map +1 -0
- package/dist/web-auth.d.ts +33 -0
- package/dist/web-auth.js +122 -0
- package/dist/web-auth.js.map +1 -0
- package/dist/web.d.ts +2 -0
- package/dist/web.js +550 -0
- package/dist/web.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { QuickstartError, runCelldQuickstart } from "./celld-quickstart.js";
|
|
8
|
+
import { validateHtml } from "./html-policy.js";
|
|
9
|
+
import { EndpointError, normalizeApiUrl, resolveConfiguredApiUrl as resolveEndpoint } from "./cli-endpoint.js";
|
|
10
|
+
import { CredentialError, CredentialStore } from "./cli-credentials.js";
|
|
11
|
+
import { ApiClientError, createAnonymousApiClient, createEnrollmentApiClient, createSavedApiClient } from "./cli-api-client.js";
|
|
12
|
+
/** Run the shared CLI with metadata supplied by its registry-specific launcher. */
|
|
13
|
+
export async function runCli(packageMetadata) {
|
|
14
|
+
class CliError extends Error {
|
|
15
|
+
}
|
|
16
|
+
const VERSION = packageMetadata.version;
|
|
17
|
+
const DEFAULT_API_URL = packageMetadata.openplan?.defaultApiUrl;
|
|
18
|
+
const OPENPLAN_DIR = path.join(os.homedir(), ".openplan");
|
|
19
|
+
const CONFIG_PATH = path.join(OPENPLAN_DIR, "config.json");
|
|
20
|
+
const credentialStore = new CredentialStore(os.homedir());
|
|
21
|
+
const DRAFTS_PATH = path.join(OPENPLAN_DIR, "drafts.json");
|
|
22
|
+
const INSTRUCTIONS_START = "<!-- openplan-celld:instructions:start -->";
|
|
23
|
+
const INSTRUCTIONS_END = "<!-- openplan-celld:instructions:end -->";
|
|
24
|
+
const program = new Command();
|
|
25
|
+
// Commander normally quotes unknown options/arguments. These may be a pasted key.
|
|
26
|
+
const ARGUMENT_ERROR = "Invalid command arguments. For secure key input use: openplan auth set --stdin [--api-url <origin>]. Run --help for usage.";
|
|
27
|
+
program.configureOutput({ outputError: () => { } });
|
|
28
|
+
// Set this before constructing subcommands so every parser inherits the safe boundary.
|
|
29
|
+
program.exitOverride();
|
|
30
|
+
program
|
|
31
|
+
.name("openplan")
|
|
32
|
+
.description("Upload static HTML drafts to Openplan.")
|
|
33
|
+
.version(VERSION);
|
|
34
|
+
const endpointCommand = program
|
|
35
|
+
.command("endpoint")
|
|
36
|
+
.description("Configure the Openplan API base URL used by future commands.");
|
|
37
|
+
endpointCommand
|
|
38
|
+
.command("set")
|
|
39
|
+
.argument("<url>", "Openplan API base URL")
|
|
40
|
+
.description("Persist the API base URL in ~/.openplan/config.json.")
|
|
41
|
+
.action((url) => {
|
|
42
|
+
const apiUrl = normalizeApiUrl(url, "API URL");
|
|
43
|
+
saveApiUrl(apiUrl);
|
|
44
|
+
console.log(`Openplan API URL saved: ${apiUrl}`);
|
|
45
|
+
if (process.env.OPENPLAN_API_URL) {
|
|
46
|
+
console.log("Note: OPENPLAN_API_URL is set and currently overrides the saved value.");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
endpointCommand
|
|
50
|
+
.command("show")
|
|
51
|
+
.description("Show the effective API base URL and where it came from.")
|
|
52
|
+
.action(() => {
|
|
53
|
+
const resolved = resolveApiUrl();
|
|
54
|
+
console.log(`API URL: ${resolved.apiUrl}`);
|
|
55
|
+
console.log(`Source: ${resolved.source}`);
|
|
56
|
+
console.log(`Config file: ${CONFIG_PATH}`);
|
|
57
|
+
});
|
|
58
|
+
endpointCommand
|
|
59
|
+
.command("reset")
|
|
60
|
+
.description("Remove the saved URL and use another configured source, if available.")
|
|
61
|
+
.action(() => {
|
|
62
|
+
removeSavedApiUrl();
|
|
63
|
+
const resolved = resolveConfiguredApiUrl();
|
|
64
|
+
console.log("Saved Openplan API URL removed.");
|
|
65
|
+
if (resolved) {
|
|
66
|
+
console.log(`API URL: ${resolved.apiUrl}`);
|
|
67
|
+
console.log(`Source: ${resolved.source}`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
console.log("No Openplan API URL is configured.");
|
|
71
|
+
}
|
|
72
|
+
if (process.env.OPENPLAN_API_URL) {
|
|
73
|
+
console.log("Note: OPENPLAN_API_URL remains active until it is unset in the environment.");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const instructionsCommand = program
|
|
77
|
+
.command("instructions")
|
|
78
|
+
.description("Manage Openplan instructions in AGENTS.md or CLAUDE.md.");
|
|
79
|
+
instructionsCommand
|
|
80
|
+
.command("print")
|
|
81
|
+
.description("Print the managed Openplan instruction block without changing a file.")
|
|
82
|
+
.option("--api-url <url>", "Use a specific Openplan API base URL in the instructions")
|
|
83
|
+
.option("--local", `Use this local CLI entry point instead of npx ${packageMetadata.name}`)
|
|
84
|
+
.action((options) => {
|
|
85
|
+
const { apiUrl } = resolveApiUrl(options.apiUrl);
|
|
86
|
+
console.log(buildAgentInstructions(apiUrl, instructionCliCommand(options.local)));
|
|
87
|
+
});
|
|
88
|
+
instructionsCommand
|
|
89
|
+
.command("add")
|
|
90
|
+
.argument("[file]", "AGENTS.md or CLAUDE.md file to update", "AGENTS.md")
|
|
91
|
+
.description("Append or refresh an idempotent Openplan instruction block.")
|
|
92
|
+
.option("--api-url <url>", "Use a specific Openplan API base URL in the instructions")
|
|
93
|
+
.option("--local", `Use this local CLI entry point instead of npx ${packageMetadata.name}`)
|
|
94
|
+
.action((file, options) => {
|
|
95
|
+
const resolvedFile = resolveInstructionFile(file);
|
|
96
|
+
const { apiUrl } = resolveApiUrl(options.apiUrl);
|
|
97
|
+
const block = buildAgentInstructions(apiUrl, instructionCliCommand(options.local));
|
|
98
|
+
const existing = fs.existsSync(resolvedFile) ? fs.readFileSync(resolvedFile, "utf8") : "";
|
|
99
|
+
const updated = upsertManagedInstructions(existing, block);
|
|
100
|
+
writeTextAtomic(resolvedFile, updated);
|
|
101
|
+
console.log(`Openplan instructions ${existing.includes(INSTRUCTIONS_START) ? "updated" : "added"}: ${resolvedFile}`);
|
|
102
|
+
});
|
|
103
|
+
instructionsCommand
|
|
104
|
+
.command("remove")
|
|
105
|
+
.argument("[file]", "AGENTS.md or CLAUDE.md file to update", "AGENTS.md")
|
|
106
|
+
.description("Remove the managed Openplan instruction block and preserve other content.")
|
|
107
|
+
.action((file) => {
|
|
108
|
+
const resolvedFile = resolveInstructionFile(file);
|
|
109
|
+
if (!fs.existsSync(resolvedFile)) {
|
|
110
|
+
console.log(`No Openplan instructions found: ${resolvedFile}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const existing = fs.readFileSync(resolvedFile, "utf8");
|
|
114
|
+
const updated = removeManagedInstructions(existing);
|
|
115
|
+
if (updated === existing) {
|
|
116
|
+
console.log(`No Openplan instructions found: ${resolvedFile}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
writeTextAtomic(resolvedFile, updated);
|
|
120
|
+
console.log(`Openplan instructions removed: ${resolvedFile}`);
|
|
121
|
+
});
|
|
122
|
+
const celldCommand = program
|
|
123
|
+
.command("celld")
|
|
124
|
+
.description("Prepare, deploy, activate, and prove Openplan on a celld fleet.");
|
|
125
|
+
celldCommand
|
|
126
|
+
.command("deploy")
|
|
127
|
+
.description("Run the resumable Node.js-first dedicated-fleet quickstart.")
|
|
128
|
+
.option("--fleet-bucket <uri>", "Dedicated celld fleet bucket (s3://, gs://, or az://).")
|
|
129
|
+
.option("--public-url <url>", "Public Openplan URL.")
|
|
130
|
+
.option("--vars-file <path>", "Protected Worker variables file outside the checkout.")
|
|
131
|
+
.option("--state-file <path>", "Resumable non-secret state file outside the checkout.")
|
|
132
|
+
.option("--fleet-mode <mode>", "Fleet boundary: auto, dedicated, or shared.", "auto")
|
|
133
|
+
.option("--node <host>", "Remote celld node; repeat in restart order.", collectOption, [])
|
|
134
|
+
.option("--remote-host <host>", "Alias for one remote celld node.", collectOption, [])
|
|
135
|
+
.option("--remote-vars-file <path>", "Absolute protected variables path on each remote node.")
|
|
136
|
+
.option("--operator-url <url>", "celld 0.4 internal listener reachable from this CLI; repeat once per node.", collectOption, [])
|
|
137
|
+
.option("--restart-program <path>", "Restart executable; no local shell wrapper is used.")
|
|
138
|
+
.option("--restart-arg <arg>", "Restart executable argument; repeat as needed.", collectOption, [])
|
|
139
|
+
.option("--yes", "Skip the deployment confirmation prompt.")
|
|
140
|
+
.option("--dry-run", "Run checks and print mutations without changing the fleet.")
|
|
141
|
+
.option("--reset", "Discard the exact state file after reviewing its target.")
|
|
142
|
+
.option("--non-interactive", "Fail on missing inputs instead of prompting.")
|
|
143
|
+
.option("--command-timeout-ms <milliseconds>", "Timeout for celld, npm, SSH, and SCP commands.", parsePositiveInteger)
|
|
144
|
+
.option("--request-timeout-ms <milliseconds>", "Timeout for health, readiness, and API requests.", parsePositiveInteger)
|
|
145
|
+
.action(async (options) => {
|
|
146
|
+
const quickstartOptions = {
|
|
147
|
+
repoRoot: process.cwd(),
|
|
148
|
+
fleetMode: options.fleetMode,
|
|
149
|
+
nodes: options.node,
|
|
150
|
+
remoteHosts: options.remoteHost,
|
|
151
|
+
operatorUrls: options.operatorUrl,
|
|
152
|
+
restartArgs: options.restartArg,
|
|
153
|
+
cliVersion: VERSION
|
|
154
|
+
};
|
|
155
|
+
await runCelldQuickstart({
|
|
156
|
+
...quickstartOptions,
|
|
157
|
+
...(options.fleetBucket ? { fleetBucket: options.fleetBucket } : {}),
|
|
158
|
+
...(options.publicUrl ? { publicUrl: options.publicUrl } : {}),
|
|
159
|
+
...(options.varsFile ? { varsFile: options.varsFile } : {}),
|
|
160
|
+
...(options.stateFile ? { stateFile: options.stateFile } : {}),
|
|
161
|
+
...(options.remoteVarsFile ? { remoteVarsFile: options.remoteVarsFile } : {}),
|
|
162
|
+
...(options.restartProgram ? { restartProgram: options.restartProgram } : {}),
|
|
163
|
+
...(options.yes ? { yes: true } : {}),
|
|
164
|
+
...(options.dryRun ? { dryRun: true } : {}),
|
|
165
|
+
...(options.reset ? { reset: true } : {}),
|
|
166
|
+
...(options.nonInteractive ? { nonInteractive: true } : {}),
|
|
167
|
+
...(options.commandTimeoutMs ? { commandTimeoutMs: options.commandTimeoutMs } : {}),
|
|
168
|
+
...(options.requestTimeoutMs ? { requestTimeoutMs: options.requestTimeoutMs } : {})
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
const authCommand = program.command("auth").description("Manage CLI authentication.");
|
|
172
|
+
authCommand
|
|
173
|
+
.command("set")
|
|
174
|
+
.description("Verify a freshly supplied key and save its origin binding (does not change the default endpoint).")
|
|
175
|
+
.option("--stdin", "Read the key from bounded standard input; never pass a key as an argument")
|
|
176
|
+
.option("--api-url <url>", "Select the origin for this key, without changing the default")
|
|
177
|
+
.action(async (options) => {
|
|
178
|
+
if (!options.stdin)
|
|
179
|
+
throw new CliError("Positional API keys are no longer accepted. Use: openplan auth set --stdin [--api-url <origin>]. Nothing saved.");
|
|
180
|
+
const { apiUrl } = resolveApiUrl(options.apiUrl);
|
|
181
|
+
await enroll(apiUrl, true);
|
|
182
|
+
});
|
|
183
|
+
authCommand
|
|
184
|
+
.command("login")
|
|
185
|
+
.description("Verify a freshly supplied API key using masked terminal input. Works over SSH.")
|
|
186
|
+
.option("--stdin", "Read the key from bounded standard input instead of a terminal")
|
|
187
|
+
.option("--api-url <url>", "Select the origin for this key, without changing the default")
|
|
188
|
+
.action(async (options) => {
|
|
189
|
+
const { apiUrl } = resolveApiUrl(options.apiUrl);
|
|
190
|
+
await enroll(apiUrl, Boolean(options.stdin));
|
|
191
|
+
});
|
|
192
|
+
authCommand
|
|
193
|
+
.command("status")
|
|
194
|
+
.description("Show local credential binding status, not server validity; use whoami for a remote check.")
|
|
195
|
+
.option("--api-url <url>", "Inspect a specific origin")
|
|
196
|
+
.action(async (options) => {
|
|
197
|
+
const { apiUrl } = resolveApiUrl(options.apiUrl);
|
|
198
|
+
console.log(`API URL: ${apiUrl}`);
|
|
199
|
+
const status = await credentialStore.status(apiUrl);
|
|
200
|
+
console.log(`Credential source: ${status.source}`);
|
|
201
|
+
console.log(`Saved binding for this origin: ${status.hasSavedCredential ? "yes" : "no"}`);
|
|
202
|
+
if (status.metadata) {
|
|
203
|
+
if (status.metadata.accountName || status.metadata.accountId)
|
|
204
|
+
console.log(`Verified account: ${status.metadata.accountName ?? "unnamed"} (${status.metadata.accountId ?? "ID unavailable"})`);
|
|
205
|
+
if (status.metadata.apiKeyName || status.metadata.apiKeyId)
|
|
206
|
+
console.log(`Verified key identity: ${status.metadata.apiKeyName ?? "unnamed"} (${status.metadata.apiKeyId ?? "ID unavailable"})`);
|
|
207
|
+
}
|
|
208
|
+
if (status.source === "environment")
|
|
209
|
+
console.log("Environment credentials shadow the saved binding; they are not persisted.");
|
|
210
|
+
if (status.hasLegacyUnbound)
|
|
211
|
+
console.log("Legacy unbound credential retained but inactive. Reauthenticate explicitly; remove it with: openplan auth logout --legacy");
|
|
212
|
+
if (status.source === "none" || status.source === "legacy")
|
|
213
|
+
console.log("Login required: openplan auth login --api-url " + apiUrl);
|
|
214
|
+
console.log("Local status only; this does not check server revocation.");
|
|
215
|
+
});
|
|
216
|
+
authCommand
|
|
217
|
+
.command("logout")
|
|
218
|
+
.description("Remove only the selected origin's saved key; this does not revoke server keys.")
|
|
219
|
+
.option("--api-url <url>", "Remove the saved binding for a specific origin")
|
|
220
|
+
.option("--legacy", "Remove only the inactive unbound legacy record")
|
|
221
|
+
.action(async (options) => {
|
|
222
|
+
if (options.legacy && options.apiUrl !== undefined)
|
|
223
|
+
throw new CliError("--legacy and --api-url are mutually exclusive. Nothing removed.");
|
|
224
|
+
if (options.legacy) {
|
|
225
|
+
const result = await credentialStore.removeLegacy();
|
|
226
|
+
console.log(result.removed ? "Legacy unbound credential removed." : "No legacy unbound credential to remove.");
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const { apiUrl } = resolveApiUrl(options.apiUrl);
|
|
230
|
+
const result = await credentialStore.remove(apiUrl);
|
|
231
|
+
console.log(`${result.removed ? "Saved credential removed" : "No saved credential"} for ${apiUrl}.`);
|
|
232
|
+
if (result.hasLegacyUnbound)
|
|
233
|
+
console.log("Inactive legacy credential retained; remove separately with: openplan auth logout --legacy");
|
|
234
|
+
}
|
|
235
|
+
if (process.env.OPENPLAN_API_KEY !== undefined || process.env.OPENPLAN_API_KEY_ORIGIN !== undefined) {
|
|
236
|
+
console.log("Environment credential settings remain active; unset OPENPLAN_API_KEY and OPENPLAN_API_KEY_ORIGIN separately.");
|
|
237
|
+
}
|
|
238
|
+
console.log("Other origin bindings are unchanged. Server keys and backups are not revoked or erased.");
|
|
239
|
+
});
|
|
240
|
+
program
|
|
241
|
+
.command("whoami")
|
|
242
|
+
.description("Check the configured Openplan credentials.")
|
|
243
|
+
.option("--api-url <url>", "Override the configured Openplan API base URL")
|
|
244
|
+
.action(async (options) => {
|
|
245
|
+
const client = await authenticatedClient(options.apiUrl);
|
|
246
|
+
const body = await client.me();
|
|
247
|
+
console.log(`Account: ${body.accountName} (${body.accountId})`);
|
|
248
|
+
console.log(`API key: ${body.apiKeyName} (${body.apiKeyId})`);
|
|
249
|
+
});
|
|
250
|
+
program
|
|
251
|
+
.command("upload")
|
|
252
|
+
.argument("<file>", "HTML file path")
|
|
253
|
+
.option("--draft <draft-id>", "Update a specific draft")
|
|
254
|
+
.option("--new", "Always create a new draft")
|
|
255
|
+
.option("--description <text>", "Set a short description for the draft")
|
|
256
|
+
.option("--api-url <url>", "Override the default Openplan API base URL")
|
|
257
|
+
.option("--anonymous", "Explicitly upload without any credential lookup or Authorization header")
|
|
258
|
+
.description("Upload or update an HTML draft (authenticated unless --anonymous).")
|
|
259
|
+
.action(async (file, options) => {
|
|
260
|
+
const resolvedFile = path.resolve(file);
|
|
261
|
+
const { apiUrl } = resolveApiUrl(options.apiUrl);
|
|
262
|
+
const client = options.anonymous
|
|
263
|
+
? createAnonymousApiClient(apiUrl, { userAgent: `openplan/${VERSION}` })
|
|
264
|
+
: await createSavedApiClient(apiUrl, credentialStore, { userAgent: `openplan/${VERSION}` });
|
|
265
|
+
if (!fs.existsSync(resolvedFile)) {
|
|
266
|
+
throw new CliError(`File does not exist: ${resolvedFile}`);
|
|
267
|
+
}
|
|
268
|
+
const html = fs.readFileSync(resolvedFile, "utf8");
|
|
269
|
+
const validation = validateHtml(html);
|
|
270
|
+
if (!validation.ok) {
|
|
271
|
+
throw new CliError(`HTML failed Openplan validation:\n- ${validation.errors.join("\n- ")}`);
|
|
272
|
+
}
|
|
273
|
+
const drafts = readDrafts();
|
|
274
|
+
const knownDraft = drafts.files?.[resolvedFile];
|
|
275
|
+
const knownDraftForEndpoint = knownDraft?.apiUrl === apiUrl ? knownDraft : undefined;
|
|
276
|
+
const draftId = options.new ? null : options.draft || knownDraftForEndpoint?.draftId || null;
|
|
277
|
+
const payload = {
|
|
278
|
+
html,
|
|
279
|
+
filename: path.basename(resolvedFile),
|
|
280
|
+
draftId,
|
|
281
|
+
description: options.description,
|
|
282
|
+
metadata: {
|
|
283
|
+
...collectGitMetadata(path.dirname(resolvedFile)),
|
|
284
|
+
...collectCiMetadata(),
|
|
285
|
+
cliVersion: VERSION,
|
|
286
|
+
fileSha256: sha256(html)
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const body = await client.upload(payload);
|
|
290
|
+
drafts.files ||= {};
|
|
291
|
+
drafts.files[resolvedFile] = {
|
|
292
|
+
draftId: body.draftId,
|
|
293
|
+
apiUrl,
|
|
294
|
+
publicUrl: body.publicUrl,
|
|
295
|
+
rawUrl: body.rawUrl || `${body.publicUrl.replace(/\/+$/, "")}/raw`,
|
|
296
|
+
latestVersionNumber: body.versionNumber,
|
|
297
|
+
updatedAt: new Date().toISOString()
|
|
298
|
+
};
|
|
299
|
+
writeJson(DRAFTS_PATH, drafts, 0o600);
|
|
300
|
+
console.log(draftId ? "Updated draft" : "Uploaded draft");
|
|
301
|
+
console.log(`Current URL: ${body.publicUrl}`);
|
|
302
|
+
console.log(`Current raw HTML: ${body.rawUrl || `${body.publicUrl.replace(/\/+$/, "")}/raw`}`);
|
|
303
|
+
console.log(`Draft ID: ${body.draftId}`);
|
|
304
|
+
console.log(`Version: ${body.versionNumber}`);
|
|
305
|
+
if (body.versionUrl)
|
|
306
|
+
console.log(`Uploaded version URL: ${body.versionUrl}`);
|
|
307
|
+
if (body.versionRawUrl)
|
|
308
|
+
console.log(`Uploaded version raw HTML: ${body.versionRawUrl}`);
|
|
309
|
+
if (body.access?.mode) {
|
|
310
|
+
console.log(`Uploaded version access: ${body.access.mode}${body.access.inherited ? " (inherits plan default)" : ""}`);
|
|
311
|
+
if (body.planAccess?.mode)
|
|
312
|
+
console.log(`Plan default: ${body.planAccess.mode}`);
|
|
313
|
+
console.log("Access is a snapshot for the uploaded version, not the current URL; a newer version or later sharing change may differ.");
|
|
314
|
+
if (body.access.mode !== "public") {
|
|
315
|
+
console.log("The uploaded version is not public at upload completion. To change sharing, use the dashboard access settings.");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
console.log("Access: not reported by server; check the dashboard before sharing this URL.");
|
|
320
|
+
}
|
|
321
|
+
for (const warning of body.warnings || []) {
|
|
322
|
+
console.warn(`Warning: ${warning}`);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
program
|
|
326
|
+
.command("list")
|
|
327
|
+
.description("List the drafts published to your account.")
|
|
328
|
+
.option("--api-url <url>", "Override the default Openplan API base URL")
|
|
329
|
+
.option("--json", "Print the raw JSON response")
|
|
330
|
+
.action(async (options) => {
|
|
331
|
+
const client = await authenticatedClient(options.apiUrl);
|
|
332
|
+
const body = await client.listDrafts();
|
|
333
|
+
const drafts = body.drafts || [];
|
|
334
|
+
if (options.json) {
|
|
335
|
+
console.log(JSON.stringify(drafts, null, 2));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!drafts.length) {
|
|
339
|
+
console.log("No drafts yet. Publish one with: openplan upload <file>");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
console.log(`Drafts (${drafts.length})\n`);
|
|
343
|
+
for (const draft of drafts) {
|
|
344
|
+
const repo = draft.repoOrg && draft.repoName ? `${draft.repoOrg}/${draft.repoName}` : "no repo";
|
|
345
|
+
const version = draft.latestVersionNumber ? `v${draft.latestVersionNumber}` : "no versions";
|
|
346
|
+
const count = `${draft.versionCount} version${draft.versionCount === 1 ? "" : "s"}`;
|
|
347
|
+
const disabled = draft.disabled ? " · disabled" : "";
|
|
348
|
+
console.log(draft.title || "Untitled Draft");
|
|
349
|
+
console.log(` ${repo} · ${version} · ${count} · updated ${timeAgo(draft.updatedAt)}${disabled}`);
|
|
350
|
+
console.log(` ${draft.publicUrl}`);
|
|
351
|
+
if (draft.description) {
|
|
352
|
+
console.log(` ${draft.description}`);
|
|
353
|
+
}
|
|
354
|
+
console.log("");
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
async function authenticatedClient(apiUrlOverride) {
|
|
358
|
+
const { apiUrl } = resolveApiUrl(apiUrlOverride);
|
|
359
|
+
return createSavedApiClient(apiUrl, credentialStore, { userAgent: `openplan/${VERSION}` });
|
|
360
|
+
}
|
|
361
|
+
// Enrollment is the only CLI boundary which handles freshly supplied secret input.
|
|
362
|
+
async function enroll(apiUrl, stdin) {
|
|
363
|
+
console.log(`Credential target: ${apiUrl}`);
|
|
364
|
+
if (!stdin)
|
|
365
|
+
console.log(`Generate a key in your browser: ${apiUrl}/cli/auth`);
|
|
366
|
+
await credentialStore.status(apiUrl, {});
|
|
367
|
+
const apiKey = stdin ? await readStdinKey() : await readMaskedKey();
|
|
368
|
+
const client = createEnrollmentApiClient(apiUrl, apiKey, { userAgent: `openplan/${VERSION}` });
|
|
369
|
+
const identity = await client.me();
|
|
370
|
+
const result = await credentialStore.save(apiUrl, apiKey, identity);
|
|
371
|
+
console.log(`Verified credential saved for ${apiUrl}.`);
|
|
372
|
+
if (result.hasLegacyUnbound)
|
|
373
|
+
console.log("A different legacy unbound credential was retained but remains inactive.");
|
|
374
|
+
console.log(`Default endpoint unchanged. To select this origin for future commands: openplan endpoint set ${apiUrl}`);
|
|
375
|
+
}
|
|
376
|
+
const MAX_KEY_BYTES = 8192;
|
|
377
|
+
function checkedKey(value) {
|
|
378
|
+
const key = value.trim();
|
|
379
|
+
if (!key || Buffer.byteLength(key) > MAX_KEY_BYTES || /[\s\x00-\x1f\x7f]/.test(key)) {
|
|
380
|
+
throw new CliError("Invalid or empty API key input. Supply one key only; nothing saved.");
|
|
381
|
+
}
|
|
382
|
+
return key;
|
|
383
|
+
}
|
|
384
|
+
function readStdinKey() {
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const chunks = [];
|
|
387
|
+
let bytes = 0;
|
|
388
|
+
const timer = setTimeout(() => finish(new CliError("API key input timed out. Nothing saved.")), 60_000);
|
|
389
|
+
const finish = (error) => {
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
process.stdin.off("data", data).off("end", end).off("error", failed);
|
|
392
|
+
process.stdin.pause();
|
|
393
|
+
if (error)
|
|
394
|
+
return reject(error);
|
|
395
|
+
try {
|
|
396
|
+
resolve(checkedKey(Buffer.concat(chunks).toString("utf8")));
|
|
397
|
+
}
|
|
398
|
+
catch (failure) {
|
|
399
|
+
reject(failure);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const data = (chunk) => {
|
|
403
|
+
bytes += chunk.length;
|
|
404
|
+
if (bytes > MAX_KEY_BYTES)
|
|
405
|
+
return finish(new CliError("API key input exceeds the size limit. Nothing saved."));
|
|
406
|
+
chunks.push(chunk);
|
|
407
|
+
};
|
|
408
|
+
const end = () => finish();
|
|
409
|
+
const failed = () => finish(new CliError("Unable to read API key input. Nothing saved."));
|
|
410
|
+
process.stdin.on("data", data).once("end", end).once("error", failed);
|
|
411
|
+
process.stdin.resume();
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
function readMaskedKey() {
|
|
415
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
416
|
+
throw new CliError("Masked login requires a terminal. Use: openplan auth set --stdin [--api-url <origin>]. Nothing saved.");
|
|
417
|
+
}
|
|
418
|
+
return new Promise((resolve, reject) => {
|
|
419
|
+
let value = "";
|
|
420
|
+
const wasRaw = process.stdin.isRaw;
|
|
421
|
+
const finish = (error) => {
|
|
422
|
+
process.stdin.off("data", data).off("end", aborted).off("error", aborted);
|
|
423
|
+
process.off("SIGTERM", aborted).off("SIGHUP", aborted).off("SIGINT", aborted);
|
|
424
|
+
process.stdin.setRawMode(wasRaw);
|
|
425
|
+
process.stdin.pause();
|
|
426
|
+
process.stdout.write("\n");
|
|
427
|
+
if (error)
|
|
428
|
+
return reject(error);
|
|
429
|
+
try {
|
|
430
|
+
resolve(checkedKey(value));
|
|
431
|
+
}
|
|
432
|
+
catch (failure) {
|
|
433
|
+
reject(failure);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const aborted = () => finish(new CliError("API key input cancelled. Nothing saved."));
|
|
437
|
+
const data = (chunk) => {
|
|
438
|
+
for (const char of chunk.toString("utf8")) {
|
|
439
|
+
if (char === "\r" || char === "\n") {
|
|
440
|
+
finish();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (char === "\u0003" || char === "\u0004") {
|
|
444
|
+
aborted();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (char === "\u007f" || char === "\b") {
|
|
448
|
+
if (value) {
|
|
449
|
+
value = value.slice(0, -1);
|
|
450
|
+
process.stdout.write("\b \b");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
else if (char >= " " && char <= "~") {
|
|
454
|
+
value += char;
|
|
455
|
+
if (Buffer.byteLength(value) > MAX_KEY_BYTES) {
|
|
456
|
+
finish(new CliError("API key input exceeds the size limit. Nothing saved."));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
process.stdout.write("*");
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
finish(new CliError("Invalid API key input. Nothing saved."));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
process.stdin.setRawMode(true);
|
|
468
|
+
process.stdin.on("data", data).once("end", aborted).once("error", aborted);
|
|
469
|
+
process.once("SIGTERM", aborted).once("SIGHUP", aborted).once("SIGINT", aborted);
|
|
470
|
+
process.stdout.write("Paste your API key (masked): ");
|
|
471
|
+
process.stdin.resume();
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
function resolveApiUrl(apiUrlOverride) {
|
|
475
|
+
const resolved = resolveConfiguredApiUrl(apiUrlOverride);
|
|
476
|
+
if (resolved)
|
|
477
|
+
return resolved;
|
|
478
|
+
throw new CliError("No Openplan API URL is configured. Run: openplan endpoint set <url>");
|
|
479
|
+
}
|
|
480
|
+
function resolveConfiguredApiUrl(apiUrlOverride) {
|
|
481
|
+
const config = readConfig();
|
|
482
|
+
return resolveEndpoint({
|
|
483
|
+
...(apiUrlOverride !== undefined ? { apiUrlOverride } : {}),
|
|
484
|
+
env: process.env,
|
|
485
|
+
...(config.apiUrl !== undefined ? { savedApiUrl: config.apiUrl } : {}),
|
|
486
|
+
...(DEFAULT_API_URL !== undefined ? { defaultApiUrl: DEFAULT_API_URL } : {})
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
// The shared nonsecret state uses the same Linux/private-file boundary as the
|
|
490
|
+
// credential backend. Instruction files deliberately use a separate writer.
|
|
491
|
+
function privateStateStat(stat, directory) {
|
|
492
|
+
if ((directory ? !stat.isDirectory() : !stat.isFile()) || stat.uid !== process.getuid?.() ||
|
|
493
|
+
(stat.mode & 0o077) !== 0 || (!directory && stat.nlink !== 1)) {
|
|
494
|
+
throw new CliError("Unsafe Openplan state ownership, permissions or file type. Inspect ~/.openplan and repair private modes without following links.");
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function sameStateFile(a, b) {
|
|
498
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
499
|
+
}
|
|
500
|
+
function stateOsCode(error) {
|
|
501
|
+
return error?.code;
|
|
502
|
+
}
|
|
503
|
+
function stateAt(directory, name) {
|
|
504
|
+
return `/proc/self/fd/${directory}/${name}`;
|
|
505
|
+
}
|
|
506
|
+
function ensureStateDir(create) {
|
|
507
|
+
let parent;
|
|
508
|
+
let directory;
|
|
509
|
+
try {
|
|
510
|
+
// Walk using inspected directory descriptors. O_NOFOLLOW applies to every
|
|
511
|
+
// home component, not just .openplan, so a symlinked ancestor cannot redirect
|
|
512
|
+
// a later mkdir or open. Ancestors need not be private (e.g. /tmp).
|
|
513
|
+
const home = path.resolve(os.homedir());
|
|
514
|
+
parent = fs.openSync(path.parse(home).root, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
515
|
+
for (const component of home.slice(path.parse(home).root.length).split(path.sep).filter(Boolean)) {
|
|
516
|
+
const next = fs.openSync(stateAt(parent, component), fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
517
|
+
fs.closeSync(parent);
|
|
518
|
+
parent = next;
|
|
519
|
+
}
|
|
520
|
+
const location = stateAt(parent, ".openplan");
|
|
521
|
+
if (create) {
|
|
522
|
+
try {
|
|
523
|
+
fs.mkdirSync(location, { mode: 0o700 });
|
|
524
|
+
fs.fsyncSync(parent);
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
if (stateOsCode(error) !== "EEXIST")
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
let before;
|
|
532
|
+
try {
|
|
533
|
+
before = fs.lstatSync(location);
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
if (!create && stateOsCode(error) === "ENOENT")
|
|
537
|
+
return undefined;
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
privateStateStat(before, true);
|
|
541
|
+
directory = fs.openSync(location, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
542
|
+
const after = fs.fstatSync(directory);
|
|
543
|
+
privateStateStat(after, true);
|
|
544
|
+
if (!sameStateFile(before, after))
|
|
545
|
+
throw new CliError("Openplan state directory changed during inspection. Nothing was written.");
|
|
546
|
+
const result = directory;
|
|
547
|
+
directory = undefined;
|
|
548
|
+
return result;
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
if (error instanceof CliError)
|
|
552
|
+
throw error;
|
|
553
|
+
throw new CliError("Cannot safely access Openplan state. Inspect the home directory, ownership, private permissions and symlinks; no unsafe path was followed.");
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
if (directory !== undefined)
|
|
557
|
+
fs.closeSync(directory);
|
|
558
|
+
if (parent !== undefined)
|
|
559
|
+
fs.closeSync(parent);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function stateName(file) {
|
|
563
|
+
if (file === CONFIG_PATH)
|
|
564
|
+
return "config.json";
|
|
565
|
+
if (file === DRAFTS_PATH)
|
|
566
|
+
return "drafts.json";
|
|
567
|
+
throw new CliError("Unsupported Openplan state file.");
|
|
568
|
+
}
|
|
569
|
+
function inspectStateFile(directory, name) {
|
|
570
|
+
let stat;
|
|
571
|
+
try {
|
|
572
|
+
stat = fs.lstatSync(stateAt(directory, name));
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
if (stateOsCode(error) === "ENOENT")
|
|
576
|
+
return undefined;
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
privateStateStat(stat, false);
|
|
580
|
+
return stat;
|
|
581
|
+
}
|
|
582
|
+
function readStateJson(file) {
|
|
583
|
+
const name = stateName(file);
|
|
584
|
+
const directory = ensureStateDir(false);
|
|
585
|
+
if (directory === undefined)
|
|
586
|
+
return undefined;
|
|
587
|
+
let fd;
|
|
588
|
+
try {
|
|
589
|
+
const before = inspectStateFile(directory, name);
|
|
590
|
+
if (!before)
|
|
591
|
+
return undefined;
|
|
592
|
+
fd = fs.openSync(stateAt(directory, name), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
593
|
+
const after = fs.fstatSync(fd);
|
|
594
|
+
privateStateStat(after, false);
|
|
595
|
+
if (!sameStateFile(before, after))
|
|
596
|
+
throw new CliError("Openplan state file changed during inspection. Nothing was written.");
|
|
597
|
+
const limit = 8 * 1024 * 1024;
|
|
598
|
+
if (after.size > limit)
|
|
599
|
+
throw new CliError("Openplan state file exceeds the supported size limit; preserve it for inspection.");
|
|
600
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
601
|
+
let length = 0;
|
|
602
|
+
while (length < buffer.length) {
|
|
603
|
+
const count = fs.readSync(fd, buffer, length, buffer.length - length, null);
|
|
604
|
+
if (!count)
|
|
605
|
+
break;
|
|
606
|
+
length += count;
|
|
607
|
+
}
|
|
608
|
+
if (length > limit)
|
|
609
|
+
throw new CliError("Openplan state file exceeds the supported size limit; preserve it for inspection.");
|
|
610
|
+
try {
|
|
611
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length)));
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
throw new CliError(`Openplan ${name === "config.json" ? "config" : "draft state"} is not valid JSON; preserve it for inspection.`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
if (error instanceof CliError)
|
|
619
|
+
throw error;
|
|
620
|
+
throw new CliError("Cannot safely read Openplan state; existing state was not replaced.");
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
if (fd !== undefined)
|
|
624
|
+
fs.closeSync(fd);
|
|
625
|
+
fs.closeSync(directory);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function saveApiUrl(apiUrl) {
|
|
629
|
+
writeJson(CONFIG_PATH, {
|
|
630
|
+
...readConfig(),
|
|
631
|
+
apiUrl
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
function removeSavedApiUrl() {
|
|
635
|
+
const config = readConfig();
|
|
636
|
+
const { apiUrl: _removed, ...remaining } = config;
|
|
637
|
+
writeJson(CONFIG_PATH, remaining);
|
|
638
|
+
}
|
|
639
|
+
function readConfig() {
|
|
640
|
+
const parsed = readStateJson(CONFIG_PATH);
|
|
641
|
+
if (parsed === undefined)
|
|
642
|
+
return {};
|
|
643
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
644
|
+
throw new CliError(`Openplan config must contain a JSON object: ${CONFIG_PATH}`);
|
|
645
|
+
}
|
|
646
|
+
if ("apiUrl" in parsed && parsed.apiUrl !== undefined && typeof parsed.apiUrl !== "string") {
|
|
647
|
+
throw new CliError(`Openplan config apiUrl must be a string: ${CONFIG_PATH}`);
|
|
648
|
+
}
|
|
649
|
+
return parsed;
|
|
650
|
+
}
|
|
651
|
+
function readDrafts() {
|
|
652
|
+
const parsed = readStateJson(DRAFTS_PATH);
|
|
653
|
+
if (parsed === undefined)
|
|
654
|
+
return { files: {} };
|
|
655
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) ||
|
|
656
|
+
!("files" in parsed) || !parsed.files || typeof parsed.files !== "object" || Array.isArray(parsed.files)) {
|
|
657
|
+
throw new CliError("Openplan draft state must contain a files object; preserve it for inspection.");
|
|
658
|
+
}
|
|
659
|
+
for (const entry of Object.values(parsed.files)) {
|
|
660
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry) || typeof entry.draftId !== "string" ||
|
|
661
|
+
(entry.apiUrl !== undefined && typeof entry.apiUrl !== "string")) {
|
|
662
|
+
throw new CliError("Openplan draft state contains an invalid record; preserve it for inspection.");
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return parsed;
|
|
666
|
+
}
|
|
667
|
+
function collectOption(value, previous) {
|
|
668
|
+
return [...previous, value];
|
|
669
|
+
}
|
|
670
|
+
function parsePositiveInteger(value) {
|
|
671
|
+
const parsed = Number(value);
|
|
672
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
673
|
+
throw new CliError("Expected a positive integer.");
|
|
674
|
+
}
|
|
675
|
+
return parsed;
|
|
676
|
+
}
|
|
677
|
+
function writeJson(file, value, _mode = 0o600) {
|
|
678
|
+
const name = stateName(file);
|
|
679
|
+
const directory = ensureStateDir(true);
|
|
680
|
+
let temporary;
|
|
681
|
+
let fd;
|
|
682
|
+
let committed = false;
|
|
683
|
+
try {
|
|
684
|
+
inspectStateFile(directory, name);
|
|
685
|
+
const serialized = `${JSON.stringify(value, null, 2)}\n`;
|
|
686
|
+
if (Buffer.byteLength(serialized) > 8 * 1024 * 1024)
|
|
687
|
+
throw new CliError("Openplan state update exceeds the supported size limit. Nothing was written.");
|
|
688
|
+
temporary = stateAt(directory, `.${name}-${randomUUID()}.tmp`);
|
|
689
|
+
fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
690
|
+
privateStateStat(fs.fstatSync(fd), false);
|
|
691
|
+
fs.writeFileSync(fd, serialized, "utf8");
|
|
692
|
+
fs.fsyncSync(fd);
|
|
693
|
+
fs.closeSync(fd);
|
|
694
|
+
fd = undefined;
|
|
695
|
+
// Recheck the destination after writing, then rename relative to the same
|
|
696
|
+
// descriptor. No chmod/repair or symlink traversal is ever performed.
|
|
697
|
+
inspectStateFile(directory, name);
|
|
698
|
+
fs.renameSync(temporary, stateAt(directory, name));
|
|
699
|
+
committed = true;
|
|
700
|
+
temporary = undefined;
|
|
701
|
+
fs.fsyncSync(directory);
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
if (committed)
|
|
705
|
+
throw new CliError("Openplan state replacement occurred but durability could not be confirmed. Persistence is uncertain; inspect local state before retrying.");
|
|
706
|
+
if (error instanceof CliError)
|
|
707
|
+
throw error;
|
|
708
|
+
throw new CliError("Openplan state update failed before replacement; existing state was preserved. Inspect local state before retrying.");
|
|
709
|
+
}
|
|
710
|
+
finally {
|
|
711
|
+
if (fd !== undefined)
|
|
712
|
+
fs.closeSync(fd);
|
|
713
|
+
if (temporary) {
|
|
714
|
+
try {
|
|
715
|
+
fs.unlinkSync(temporary);
|
|
716
|
+
}
|
|
717
|
+
catch { /* Do not obscure the original failure. */ }
|
|
718
|
+
}
|
|
719
|
+
fs.closeSync(directory);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function resolveInstructionFile(file) {
|
|
723
|
+
const resolved = path.resolve(file);
|
|
724
|
+
const basename = path.basename(resolved);
|
|
725
|
+
if (basename !== "AGENTS.md" && basename !== "CLAUDE.md") {
|
|
726
|
+
throw new CliError("Instruction file must be named AGENTS.md or CLAUDE.md.");
|
|
727
|
+
}
|
|
728
|
+
if (!fs.existsSync(path.dirname(resolved))) {
|
|
729
|
+
throw new CliError(`Instruction file directory does not exist: ${path.dirname(resolved)}`);
|
|
730
|
+
}
|
|
731
|
+
return resolved;
|
|
732
|
+
}
|
|
733
|
+
function instructionCliCommand(local) {
|
|
734
|
+
if (local && packageMetadata.localInstructionCommand)
|
|
735
|
+
return packageMetadata.localInstructionCommand;
|
|
736
|
+
if (!local && packageMetadata.instructionCommand)
|
|
737
|
+
return packageMetadata.instructionCommand;
|
|
738
|
+
if (!local)
|
|
739
|
+
return `npx ${packageMetadata.name}`;
|
|
740
|
+
const entryPoint = path.resolve(process.argv[1] || "dist/cli.js");
|
|
741
|
+
const loader = entryPoint.endsWith(".ts") ? " --import tsx" : "";
|
|
742
|
+
return `node${loader} ${JSON.stringify(entryPoint)}`;
|
|
743
|
+
}
|
|
744
|
+
function buildAgentInstructions(apiUrl, cliCommand) {
|
|
745
|
+
return `${INSTRUCTIONS_START}
|
|
746
|
+
## Openplan
|
|
747
|
+
|
|
748
|
+
Use Openplan when asked to publish a plan, proposal, brief, architecture note, or other static HTML artifact.
|
|
749
|
+
|
|
750
|
+
Before uploading, select this repository's Openplan deployment (endpoint selection never moves credentials):
|
|
751
|
+
|
|
752
|
+
\`\`\`sh
|
|
753
|
+
${cliCommand} endpoint set ${apiUrl}
|
|
754
|
+
${cliCommand} endpoint show
|
|
755
|
+
\`\`\`
|
|
756
|
+
|
|
757
|
+
Authenticate separately with \`${cliCommand} auth login --api-url ${apiUrl}\`. A key is verified and saved only for that exact origin; the default endpoint is unchanged. For automation use \`auth set --stdin\`, never a positional key. Uploads require authentication unless explicitly \`--anonymous\`.
|
|
758
|
+
|
|
759
|
+
Create one complete, self-contained HTML document. CSS is allowed, but do not include JavaScript, scripts, inline event handlers, forms, iframes, embeds, meta-refresh redirects, secrets, tokens, or local filesystem paths.
|
|
760
|
+
|
|
761
|
+
Upload the document with:
|
|
762
|
+
|
|
763
|
+
\`\`\`sh
|
|
764
|
+
${cliCommand} upload <file-path>
|
|
765
|
+
\`\`\`
|
|
766
|
+
|
|
767
|
+
The CLI prints a draft URL and a raw HTML URL. Return the draft URL to the user. Re-uploading the same local file updates its existing draft; use \`--new\` only when a separate draft is requested.
|
|
768
|
+
${INSTRUCTIONS_END}`;
|
|
769
|
+
}
|
|
770
|
+
function upsertManagedInstructions(existing, block) {
|
|
771
|
+
const range = managedInstructionRange(existing);
|
|
772
|
+
if (!range) {
|
|
773
|
+
const prefix = existing.trimEnd();
|
|
774
|
+
return `${prefix ? `${prefix}\n\n` : ""}${block}\n`;
|
|
775
|
+
}
|
|
776
|
+
const prefix = existing.slice(0, range.start).trimEnd();
|
|
777
|
+
const suffix = existing.slice(range.end).trim();
|
|
778
|
+
return [prefix, block, suffix].filter(Boolean).join("\n\n") + "\n";
|
|
779
|
+
}
|
|
780
|
+
function removeManagedInstructions(existing) {
|
|
781
|
+
const range = managedInstructionRange(existing);
|
|
782
|
+
if (!range)
|
|
783
|
+
return existing;
|
|
784
|
+
const prefix = existing.slice(0, range.start).trimEnd();
|
|
785
|
+
const suffix = existing.slice(range.end).trim();
|
|
786
|
+
const remaining = [prefix, suffix].filter(Boolean).join("\n\n");
|
|
787
|
+
return remaining ? `${remaining}\n` : "";
|
|
788
|
+
}
|
|
789
|
+
function managedInstructionRange(content) {
|
|
790
|
+
const start = content.indexOf(INSTRUCTIONS_START);
|
|
791
|
+
const endMarkerStart = content.indexOf(INSTRUCTIONS_END);
|
|
792
|
+
if (start === -1 && endMarkerStart === -1)
|
|
793
|
+
return null;
|
|
794
|
+
if (start === -1 || endMarkerStart === -1 || endMarkerStart < start) {
|
|
795
|
+
throw new CliError("The Openplan instruction markers are incomplete or out of order.");
|
|
796
|
+
}
|
|
797
|
+
const duplicateStart = content.indexOf(INSTRUCTIONS_START, start + INSTRUCTIONS_START.length);
|
|
798
|
+
const duplicateEnd = content.indexOf(INSTRUCTIONS_END, endMarkerStart + INSTRUCTIONS_END.length);
|
|
799
|
+
if (duplicateStart !== -1 || duplicateEnd !== -1) {
|
|
800
|
+
throw new CliError("Multiple Openplan instruction blocks were found; remove duplicates manually.");
|
|
801
|
+
}
|
|
802
|
+
return { start, end: endMarkerStart + INSTRUCTIONS_END.length };
|
|
803
|
+
}
|
|
804
|
+
function writeTextAtomic(file, value) {
|
|
805
|
+
const existingMode = fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : 0o644;
|
|
806
|
+
const temporaryFile = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
807
|
+
try {
|
|
808
|
+
fs.writeFileSync(temporaryFile, value, { mode: existingMode });
|
|
809
|
+
fs.chmodSync(temporaryFile, existingMode);
|
|
810
|
+
fs.renameSync(temporaryFile, file);
|
|
811
|
+
}
|
|
812
|
+
finally {
|
|
813
|
+
fs.rmSync(temporaryFile, { force: true });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function collectGitMetadata(cwd) {
|
|
817
|
+
const repoRoot = git(["rev-parse", "--show-toplevel"], cwd);
|
|
818
|
+
const remote = git(["config", "--get", "remote.origin.url"], cwd);
|
|
819
|
+
const parsedRemote = parseRemote(remote);
|
|
820
|
+
const status = git(["status", "--porcelain"], cwd);
|
|
821
|
+
return {
|
|
822
|
+
repoOrg: parsedRemote.org || inferOrgFromRoot(repoRoot),
|
|
823
|
+
repoName: parsedRemote.name || (repoRoot ? path.basename(repoRoot) : null),
|
|
824
|
+
repoHost: parsedRemote.host || null,
|
|
825
|
+
gitBranch: git(["rev-parse", "--abbrev-ref", "HEAD"], cwd),
|
|
826
|
+
gitCommitSha: git(["rev-parse", "HEAD"], cwd),
|
|
827
|
+
gitCommitSubject: git(["log", "-1", "--format=%s"], cwd),
|
|
828
|
+
// null when not a git repo; true/false when a working tree is present.
|
|
829
|
+
gitDirty: status === null ? null : status.length > 0
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
// Best-effort CI provenance. GitHub Actions is detected precisely (with a run
|
|
833
|
+
// URL); other CI systems are flagged generically. Nothing here is trusted for
|
|
834
|
+
// authorization — it is metadata for the dashboard and audit trail only.
|
|
835
|
+
function collectCiMetadata() {
|
|
836
|
+
const env = process.env;
|
|
837
|
+
if (env.GITHUB_ACTIONS === "true") {
|
|
838
|
+
const server = env.GITHUB_SERVER_URL || "https://github.com";
|
|
839
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
840
|
+
const runId = env.GITHUB_RUN_ID;
|
|
841
|
+
return {
|
|
842
|
+
ciProvider: "github_actions",
|
|
843
|
+
ciRunUrl: repo && runId ? `${server}/${repo}/actions/runs/${runId}` : null,
|
|
844
|
+
ciActor: env.GITHUB_ACTOR || null
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
if (env.CI) {
|
|
848
|
+
return { ciProvider: "unknown" };
|
|
849
|
+
}
|
|
850
|
+
return {};
|
|
851
|
+
}
|
|
852
|
+
function git(args, cwd) {
|
|
853
|
+
try {
|
|
854
|
+
return execFileSync("git", args, {
|
|
855
|
+
cwd,
|
|
856
|
+
encoding: "utf8",
|
|
857
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
858
|
+
}).trim();
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
function parseRemote(remote) {
|
|
865
|
+
if (!remote)
|
|
866
|
+
return {};
|
|
867
|
+
const cleaned = remote.replace(/\.git$/, "");
|
|
868
|
+
const sshMatch = cleaned.match(/^[^@]+@([^:]+):([^/]+)\/(.+)$/);
|
|
869
|
+
if (sshMatch) {
|
|
870
|
+
return { host: sshMatch[1], org: sshMatch[2], name: path.basename(sshMatch[3]) };
|
|
871
|
+
}
|
|
872
|
+
try {
|
|
873
|
+
const url = new URL(cleaned);
|
|
874
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
875
|
+
if (parts.length >= 2) {
|
|
876
|
+
return { host: url.hostname, org: parts[0], name: parts.at(-1) };
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// Fall through to path parsing.
|
|
881
|
+
}
|
|
882
|
+
const parts = cleaned.split("/").filter(Boolean);
|
|
883
|
+
if (parts.length >= 2) {
|
|
884
|
+
return { org: parts.at(-2), name: parts.at(-1) };
|
|
885
|
+
}
|
|
886
|
+
return {};
|
|
887
|
+
}
|
|
888
|
+
function inferOrgFromRoot(repoRoot) {
|
|
889
|
+
if (!repoRoot)
|
|
890
|
+
return null;
|
|
891
|
+
return path.basename(path.dirname(repoRoot));
|
|
892
|
+
}
|
|
893
|
+
function sha256(value) {
|
|
894
|
+
return createHash("sha256").update(value).digest("hex");
|
|
895
|
+
}
|
|
896
|
+
function timeAgo(value) {
|
|
897
|
+
if (!value)
|
|
898
|
+
return "unknown";
|
|
899
|
+
if (!(typeof value === "string" || typeof value === "number" || value instanceof Date)) {
|
|
900
|
+
return "unknown";
|
|
901
|
+
}
|
|
902
|
+
const then = new Date(value).getTime();
|
|
903
|
+
if (Number.isNaN(then))
|
|
904
|
+
return "unknown";
|
|
905
|
+
const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
|
906
|
+
const units = [
|
|
907
|
+
["year", 31_536_000],
|
|
908
|
+
["month", 2_592_000],
|
|
909
|
+
["week", 604_800],
|
|
910
|
+
["day", 86_400],
|
|
911
|
+
["hour", 3_600],
|
|
912
|
+
["minute", 60]
|
|
913
|
+
];
|
|
914
|
+
for (const [name, secs] of units) {
|
|
915
|
+
const amount = Math.floor(seconds / secs);
|
|
916
|
+
if (amount >= 1)
|
|
917
|
+
return `${amount} ${name}${amount === 1 ? "" : "s"} ago`;
|
|
918
|
+
}
|
|
919
|
+
return "just now";
|
|
920
|
+
}
|
|
921
|
+
await program.parseAsync(process.argv).catch((error) => {
|
|
922
|
+
if (error instanceof CliError || error instanceof EndpointError || error instanceof CredentialError || error instanceof ApiClientError || error instanceof QuickstartError) {
|
|
923
|
+
console.error(error.message);
|
|
924
|
+
process.exit(1);
|
|
925
|
+
}
|
|
926
|
+
const commandError = error;
|
|
927
|
+
if (commandError.code === "commander.helpDisplayed" || commandError.code === "commander.version") {
|
|
928
|
+
process.exit(0);
|
|
929
|
+
}
|
|
930
|
+
console.error(commandError.code?.startsWith("commander.") ? ARGUMENT_ERROR : "Openplan operation failed. No diagnostic response body is displayed.");
|
|
931
|
+
process.exit(1);
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
//# sourceMappingURL=cli-program.js.map
|